Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce33ebf9b0 | ||
|
|
26ef92d0a3 | ||
|
|
4840a530b6 | ||
|
|
6804665b95 | ||
|
|
017d5f128e | ||
|
|
86f28a1299 | ||
|
|
f71207aeb5 | ||
|
|
73e9bc6a1c | ||
|
|
9c07756e42 | ||
|
|
e615aa1227 | ||
|
|
23a08621db | ||
|
|
676e58cad7 | ||
|
|
3413c3e689 | ||
|
|
9d8c4538c7 | ||
|
|
b40be693ee | ||
|
|
da3d71d124 | ||
|
|
4bb4c83054 | ||
|
|
243083261f | ||
|
|
5e8d0f4921 | ||
|
|
96fa34cd86 | ||
|
|
39ca7769fa | ||
|
|
eb78359b10 | ||
|
|
72b027a29c | ||
|
|
a385ed5de3 | ||
|
|
4902a0b5a9 | ||
|
|
a5b432fb7b | ||
|
|
e726f43f7c | ||
|
|
3d683d99a5 | ||
|
|
7cd9d1273b | ||
|
|
bd738d4bc0 |
@@ -12,6 +12,7 @@ node_modules/
|
||||
# Playwright
|
||||
playwright-report/
|
||||
blob-report/
|
||||
test-results/
|
||||
|
||||
# Docker
|
||||
**/.DS_Store
|
||||
@@ -22,3 +23,5 @@ blob-report/
|
||||
MAIN_PLAN.md
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
uploads/
|
||||
@@ -0,0 +1,220 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// 대시보드 개요 API (/api/dashboard/overview)
|
||||
// - 로그인한 사용자의 프로젝트/폼 제출 통계를 집계해 요약/프로젝트별 지표를 반환한다.
|
||||
// - DB 스키마: Project, FormSubmission
|
||||
// - 인증: pb_access 쿠키(JWT)를 검증해 현재 사용자 식별
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다.
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다.
|
||||
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
interface SummaryStats {
|
||||
totalProjects: number;
|
||||
totalSubmissions: number;
|
||||
todaySubmissions: number;
|
||||
last7DaysSubmissions: number;
|
||||
}
|
||||
|
||||
interface DailySubmissionItem {
|
||||
// 일별 제출 수 그래프를 위한 집계 데이터
|
||||
date: string; // YYYY-MM-DD (UTC 기준)
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ProjectStatsItem {
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
totalSubmissions: number;
|
||||
lastSubmissionAt: string | null;
|
||||
}
|
||||
|
||||
// GET /api/dashboard/overview
|
||||
// - 현재 로그인 사용자의 프로젝트 목록/폼 제출 내역을 조회해 통계를 계산한다.
|
||||
export async function GET(request: Request) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json(
|
||||
{ message: "대시보드를 조회하려면 로그인이 필요합니다." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// 현재 사용자 소유 프로젝트와, 해당 사용자가 소유한 제출 내역을 한 번에 로드한다.
|
||||
const [projects, submissions] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
where: { userId: authUser.id },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
prisma.formSubmission.findMany({
|
||||
where: { userId: authUser.id },
|
||||
select: {
|
||||
id: true,
|
||||
projectId: true,
|
||||
projectSlug: true,
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const totalProjects = projects.length;
|
||||
const totalSubmissions = submissions.length;
|
||||
|
||||
// 오늘/최근 7일 기준 시각 계산
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let todaySubmissions = 0;
|
||||
let last7DaysSubmissions = 0;
|
||||
|
||||
for (const submission of submissions) {
|
||||
const createdAt = submission.createdAt instanceof Date
|
||||
? submission.createdAt
|
||||
: new Date(submission.createdAt as unknown as string);
|
||||
|
||||
if (createdAt >= startOfToday) {
|
||||
todaySubmissions += 1;
|
||||
}
|
||||
|
||||
if (createdAt >= sevenDaysAgo) {
|
||||
last7DaysSubmissions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트별 통계 맵을 구성한다.
|
||||
const statsByProjectId = new Map<
|
||||
string,
|
||||
{
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
totalSubmissions: number;
|
||||
lastSubmissionAt: Date | null;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const project of projects) {
|
||||
statsByProjectId.set(project.id, {
|
||||
projectId: project.id,
|
||||
title: project.title,
|
||||
slug: project.slug,
|
||||
status: project.status,
|
||||
totalSubmissions: 0,
|
||||
lastSubmissionAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const submission of submissions) {
|
||||
const stat = submission.projectId ? statsByProjectId.get(submission.projectId) : null;
|
||||
if (!stat) continue;
|
||||
|
||||
stat.totalSubmissions += 1;
|
||||
|
||||
const createdAt = submission.createdAt instanceof Date
|
||||
? submission.createdAt
|
||||
: new Date(submission.createdAt as unknown as string);
|
||||
|
||||
if (!stat.lastSubmissionAt || createdAt > stat.lastSubmissionAt) {
|
||||
stat.lastSubmissionAt = createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
const projectStats: ProjectStatsItem[] = Array.from(statsByProjectId.values()).map((item) => ({
|
||||
projectId: item.projectId,
|
||||
title: item.title,
|
||||
slug: item.slug,
|
||||
status: item.status,
|
||||
totalSubmissions: item.totalSubmissions,
|
||||
lastSubmissionAt: item.lastSubmissionAt ? item.lastSubmissionAt.toISOString() : null,
|
||||
}));
|
||||
|
||||
const summaryStats: SummaryStats = {
|
||||
totalProjects,
|
||||
totalSubmissions,
|
||||
todaySubmissions,
|
||||
last7DaysSubmissions,
|
||||
};
|
||||
|
||||
// 일별 전체 제출 수를 집계해 그래프용 데이터로 변환한다.
|
||||
const countsByDate = new Map<string, number>();
|
||||
|
||||
for (const submission of submissions) {
|
||||
const createdAt = submission.createdAt instanceof Date
|
||||
? submission.createdAt
|
||||
: new Date(submission.createdAt as unknown as string);
|
||||
|
||||
const dateKey = createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const prev = countsByDate.get(dateKey) ?? 0;
|
||||
countsByDate.set(dateKey, prev + 1);
|
||||
}
|
||||
|
||||
const dailySubmissions: DailySubmissionItem[] = Array.from(countsByDate.entries())
|
||||
.map(([date, count]) => ({ date, count }))
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
summaryStats,
|
||||
projectStats,
|
||||
dailySubmissions,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "대시보드 데이터를 계산하는 중 오류가 발생했습니다.",
|
||||
error: error?.message,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
+233
-26
@@ -81,8 +81,10 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const basePart = "margin:0 auto;padding:24px;box-sizing:border-box;";
|
||||
const canvasStyle = `${widthPart}${bgPart}${basePart}`;
|
||||
|
||||
const bodyBgRaw = (projectConfig?.bodyBgColorHex ?? "#020617").trim() || "#020617";
|
||||
const bodyStyle = `background-color:${bodyBgRaw};margin:0;padding:0;`;
|
||||
const bodyBgRaw =
|
||||
typeof projectConfig?.bodyBgColorHex === "string" ? projectConfig.bodyBgColorHex.trim() : "";
|
||||
const bodyBgPart = bodyBgRaw ? `background-color:${bodyBgRaw};` : "";
|
||||
const bodyStyle = `${bodyBgPart}margin:0;padding:0;`;
|
||||
const trackingRaw = (projectConfig?.trackingScript ?? "").trim();
|
||||
const trackingHtml = trackingRaw ? `\n${trackingRaw}\n` : "";
|
||||
|
||||
@@ -161,20 +163,47 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||
|
||||
// Form 컨트롤러(FormBlock)와 개별 필드 블록(formInput/formSelect/...) 를 연결하기 위한 맵.
|
||||
// - key: 필드 블록 id (fieldIds 에 포함된 id)
|
||||
// - value: 해당 필드가 속한 <form> 요소의 DOM id (예: form_form_1)
|
||||
// - fieldIdToFormId: key = 필드 블록 id (fieldIds 에 포함된 id), value = <form> 요소의 DOM id
|
||||
// - formBlockIdToFormDomId: key = FormBlock id, value = <form> 요소의 DOM id
|
||||
const fieldIdToFormId = new Map<string, string>();
|
||||
const formBlockIdToFormDomId = new Map<string, string>();
|
||||
const submitButtonIdToFormDomId = new Map<string, string>();
|
||||
const requiredFieldIdSet = new Set<string>();
|
||||
// 테스트 및 기존 Export HTML 과의 호환성을 위해 FormBlock 의 DOM id 규칙을 다음과 같이 정의한다.
|
||||
// - block.id 가 "form_숫자" 패턴이면 기존과 동일하게 formDomId = `form_${block.id}` (예: form_1 → form_form_1)
|
||||
// - 그 외의 id 에 대해서는 pb_form_N 패턴을 사용해, form="..." 값에 우연히 "required" 같은 단어가 포함되지 않도록 한다.
|
||||
let fallbackFormIndex = 1;
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "form") continue;
|
||||
const props = (block.props ?? {}) as FormBlockProps;
|
||||
const fieldIds = Array.isArray(props.fieldIds) ? props.fieldIds : [];
|
||||
if (fieldIds.length === 0) continue;
|
||||
const formDomId = `form_${block.id}`;
|
||||
|
||||
let formDomId: string;
|
||||
if (typeof block.id === "string" && /^form_\d+$/.test(block.id)) {
|
||||
formDomId = `form_${block.id}`;
|
||||
} else {
|
||||
formDomId = `pb_form_${fallbackFormIndex++}`;
|
||||
}
|
||||
|
||||
formBlockIdToFormDomId.set(block.id, formDomId);
|
||||
for (const fieldId of fieldIds) {
|
||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||
fieldIdToFormId.set(fieldId, formDomId);
|
||||
}
|
||||
}
|
||||
|
||||
const submitButtonIdRaw = props.submitButtonId;
|
||||
if (typeof submitButtonIdRaw === "string" && submitButtonIdRaw.trim() !== "") {
|
||||
submitButtonIdToFormDomId.set(submitButtonIdRaw, formDomId);
|
||||
}
|
||||
|
||||
const requiredFieldIds = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
|
||||
for (const fieldId of requiredFieldIds) {
|
||||
if (typeof fieldId === "string" && fieldId.trim() !== "") {
|
||||
requiredFieldIdSet.add(fieldId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const renderBlock = (block: Block): string => {
|
||||
@@ -239,6 +268,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
widthPx: props.widthPx ?? undefined,
|
||||
paddingX: props.paddingX ?? undefined,
|
||||
paddingY: props.paddingY ?? undefined,
|
||||
fontSizeCustom: props.fontSizeCustom ?? undefined,
|
||||
lineHeightCustom: props.lineHeightCustom ?? undefined,
|
||||
letterSpacingCustom: props.letterSpacingCustom ?? undefined,
|
||||
fillColorCustom: props.fillColorCustom ?? undefined,
|
||||
strokeColorCustom: props.strokeColorCustom ?? undefined,
|
||||
textColorCustom: props.textColorCustom ?? undefined,
|
||||
@@ -260,6 +292,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
innerHtml = `<img src="${imgSrc}" alt="${imgAlt}" />${innerHtml}`;
|
||||
}
|
||||
|
||||
const submitFormId = submitButtonIdToFormDomId.get(block.id);
|
||||
if (submitFormId) {
|
||||
return `<div class="${tokens.alignClass}"><button type="submit" form="${escapeAttr(
|
||||
submitFormId,
|
||||
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</button></div>`;
|
||||
}
|
||||
|
||||
return `<div class="${tokens.alignClass}"><a href="${escapeAttr(
|
||||
href,
|
||||
)}" class="${btnClasses}"${styleAttr}>${innerHtml}</a></div>`;
|
||||
@@ -355,7 +394,7 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
// 컨트롤러 전용 FormBlock: fieldIds 기반으로 개별 필드 블록과 연결되는 순수 컨트롤러 폼만 생성한다.
|
||||
// - Export 에서는 레이아웃/스타일 대신 id/메서드/액션만 가지는 최소한의 <form> 요소만 만든다.
|
||||
// - action 은 퍼블릭 페이지에서 우리 서버의 forms/submit 엔드포인트로 전송하기 위한 고정 경로다.
|
||||
const formId = `form_${block.id}`;
|
||||
const formId = formBlockIdToFormDomId.get(block.id) ?? `form_${block.id}`;
|
||||
const method = props.method ?? "POST";
|
||||
const methodAttr = ` method="${method.toLowerCase()}"`;
|
||||
const actionAttr = ` action="/api/forms/submit"`;
|
||||
@@ -397,7 +436,9 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
return "";
|
||||
}
|
||||
|
||||
const lis = tokens.items.map((text) => `<li>${escapeHtml(text)}</li>`).join("");
|
||||
const lis = tokens.items
|
||||
.map((text) => `<li>${escapeHtml(text)}</li>`)
|
||||
.join("");
|
||||
const listStyleAttr = tokens.listStyleParts.join(";");
|
||||
|
||||
return `<${tokens.Tag} class="pb-list" style="${listStyleAttr}">${lis}</${tokens.Tag}>`;
|
||||
@@ -409,18 +450,55 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const label =
|
||||
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
||||
const type = props.inputType === "email" ? "email" : "text";
|
||||
const required = props.required ? " required" : "";
|
||||
const labelDisplay = props.labelDisplay ?? "visible";
|
||||
const isFloating = labelDisplay === "floating";
|
||||
const isControllerRequired = requiredFieldIdSet.has(block.id);
|
||||
const required = props.required || isControllerRequired ? " required" : "";
|
||||
|
||||
const tokens = computeFormInputExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const inputId = name;
|
||||
|
||||
const fieldClass = isFloating ? "pb-form-field pb-form-field--floating" : "pb-form-field";
|
||||
const cssVars: string[] = [];
|
||||
if (isFloating && typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const em = props.paddingY / 16;
|
||||
cssVars.push(`--pb-input-padding-y:${em}rem`);
|
||||
}
|
||||
|
||||
// strokeColorCustom 이 있으면 플로팅 라벨 패치 색으로 사용할 CSS 변수에 전달한다.
|
||||
if (typeof props.strokeColorCustom === "string" && props.strokeColorCustom.trim() !== "") {
|
||||
cssVars.push(`--pb-input-border-color:${escapeAttr(props.strokeColorCustom.trim())}`);
|
||||
}
|
||||
|
||||
const fieldStyleAttr = cssVars.length > 0 ? ` style="${cssVars.join(";")}"` : "";
|
||||
|
||||
const placeholderBase =
|
||||
typeof props.placeholder === "string" && props.placeholder.trim() !== ""
|
||||
? props.placeholder.trim()
|
||||
: "";
|
||||
|
||||
let placeholder: string;
|
||||
if (isFloating) {
|
||||
placeholder = " ";
|
||||
} else if (placeholderBase !== "") {
|
||||
placeholder = placeholderBase;
|
||||
} else {
|
||||
placeholder = label;
|
||||
}
|
||||
|
||||
const labelClass = labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
return [
|
||||
'<div class="pb-form-field">',
|
||||
`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`,
|
||||
`<input class="pb-input" type="${type}" name="${escapeAttr(name)}" placeholder="${escapeAttr(
|
||||
label,
|
||||
`<div class="${fieldClass}"${fieldStyleAttr}>`,
|
||||
`<input class="pb-input" name="${escapeAttr(name)}" id="${escapeAttr(inputId)}" type="${type}" placeholder="${escapeAttr(
|
||||
placeholder,
|
||||
)}"${required}${tokens.inputStyleAttr}${formAttr} />`,
|
||||
`<label class="${labelClass}" for="${escapeAttr(inputId)}"${tokens.labelStyleAttr}>${escapeHtml(
|
||||
label,
|
||||
)}</label>`,
|
||||
"</div>",
|
||||
].join("");
|
||||
}
|
||||
@@ -430,16 +508,42 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const name = typeof props.formFieldName === "string" ? props.formFieldName : block.id;
|
||||
const label =
|
||||
typeof props.label === "string" && props.label.trim() !== "" ? props.label : name;
|
||||
const required = props.required ? " required" : "";
|
||||
const labelDisplay = props.labelDisplay ?? "visible";
|
||||
const isControllerRequired = requiredFieldIdSet.has(block.id);
|
||||
const required = props.required || isControllerRequired ? " required" : "";
|
||||
const options: FormSelectOption[] = Array.isArray(props.options) ? props.options : [];
|
||||
|
||||
const tokens = computeFormSelectExportTokens(props, { escapeAttr });
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const fieldClass = "pb-form-field";
|
||||
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelDisplay === "visible" && labelLayout === "inline";
|
||||
const styleParts: string[] = [];
|
||||
if (isInlineLayout) {
|
||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
||||
const gapEm = gapPx / 16;
|
||||
styleParts.push("display:flex");
|
||||
styleParts.push("flex-direction:row");
|
||||
styleParts.push("align-items:center");
|
||||
styleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
styleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const fieldStyleAttr = styleParts.length > 0 ? ` style="${styleParts.join(";")}"` : "";
|
||||
|
||||
const labelClass =
|
||||
labelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<div class="${fieldClass}"${fieldStyleAttr}>`);
|
||||
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(
|
||||
`<select class="pb-select" name="${escapeAttr(name)}"${required}${tokens.selectStyleAttr}${formAttr}>`,
|
||||
);
|
||||
@@ -467,16 +571,65 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const groupLabelDisplay = props.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline";
|
||||
const fieldStyleParts: string[] = [];
|
||||
if (isInlineLayout) {
|
||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
||||
const gapEm = gapPx / 16;
|
||||
fieldStyleParts.push("display:flex");
|
||||
fieldStyleParts.push("flex-direction:row");
|
||||
fieldStyleParts.push("align-items:center");
|
||||
fieldStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
fieldStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const fieldStyleAttr =
|
||||
fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : "";
|
||||
|
||||
const labelClass =
|
||||
groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
const optionLayout = props.optionLayout ?? "stacked";
|
||||
const optionsClass =
|
||||
optionLayout === "inline"
|
||||
? "pb-form-options pb-form-options--inline"
|
||||
: "pb-form-options pb-form-options--stacked";
|
||||
|
||||
const optionsStyleParts: string[] = [];
|
||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||
const gapEm = props.optionGapPx / 16;
|
||||
optionsStyleParts.push(`row-gap:${gapEm}em`);
|
||||
if (optionLayout === "inline") {
|
||||
optionsStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
}
|
||||
|
||||
const optionsStyleAttr =
|
||||
optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : "";
|
||||
|
||||
const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(`<div class="pb-form-field"${fieldStyleAttr}>`);
|
||||
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<div class="${optionsClass}"${optionsStyleAttr}>`);
|
||||
options.forEach((opt: { value: string; label: string }, index: number) => {
|
||||
const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : "";
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="checkbox" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> <span>${escapeHtml(
|
||||
opt.label,
|
||||
)}</span></label>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
parts.push("</div>");
|
||||
parts.push("</div>");
|
||||
|
||||
return parts.join("");
|
||||
@@ -495,16 +648,66 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const formId = fieldIdToFormId.get(block.id);
|
||||
const formAttr = formId ? ` form="${escapeAttr(formId)}"` : "";
|
||||
|
||||
const groupLabelDisplay = props.groupLabelDisplay ?? "visible";
|
||||
const labelLayout = props.labelLayout ?? "stacked";
|
||||
const isInlineLayout = groupLabelDisplay === "visible" && labelLayout === "inline";
|
||||
|
||||
const fieldStyleParts: string[] = [];
|
||||
if (isInlineLayout) {
|
||||
const gapPx = typeof props.labelGapPx === "number" ? props.labelGapPx : 8;
|
||||
const gapEm = gapPx / 16;
|
||||
fieldStyleParts.push("display:flex");
|
||||
fieldStyleParts.push("flex-direction:row");
|
||||
fieldStyleParts.push("align-items:center");
|
||||
fieldStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
fieldStyleParts.push(`width:${props.widthPx}px`);
|
||||
}
|
||||
|
||||
const fieldStyleAttr =
|
||||
fieldStyleParts.length > 0 ? ` style="${fieldStyleParts.join(";")}"` : "";
|
||||
|
||||
const labelClass =
|
||||
groupLabelDisplay === "hidden" ? "pb-form-label sr-only" : "pb-form-label";
|
||||
|
||||
const optionLayout = props.optionLayout ?? "stacked";
|
||||
const optionsClass =
|
||||
optionLayout === "inline"
|
||||
? "pb-form-options pb-form-options--inline"
|
||||
: "pb-form-options pb-form-options--stacked";
|
||||
|
||||
const optionsStyleParts: string[] = [];
|
||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||
const gapEm = props.optionGapPx / 16;
|
||||
optionsStyleParts.push(`row-gap:${gapEm}em`);
|
||||
if (optionLayout === "inline") {
|
||||
optionsStyleParts.push(`column-gap:${gapEm}em`);
|
||||
}
|
||||
}
|
||||
|
||||
const optionsStyleAttr =
|
||||
optionsStyleParts.length > 0 ? ` style="${optionsStyleParts.join(";")}"` : "";
|
||||
|
||||
const isGroupRequired = requiredFieldIdSet.has(block.id) || props.required === true;
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push('<div class="pb-form-field">');
|
||||
parts.push(`<label class="pb-form-label"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
for (const opt of options) {
|
||||
parts.push(`<div class="pb-form-field"${fieldStyleAttr}>`);
|
||||
parts.push(`<label class="${labelClass}"${tokens.labelStyleAttr}>${escapeHtml(label)}</label>`);
|
||||
parts.push(`<div class="${optionsClass}"${optionsStyleAttr}>`);
|
||||
options.forEach((opt: { value: string; label: string }, index: number) => {
|
||||
const optionRequiredAttr = isGroupRequired && index === 0 ? " required" : "";
|
||||
parts.push(
|
||||
`<label class="pb-form-option"${tokens.optionStyleAttr}><input type="radio" name="${escapeAttr(
|
||||
name,
|
||||
)}" value="${escapeAttr(opt.value)}"${formAttr} /> ${escapeHtml(opt.label)}</label>`,
|
||||
)}" value="${escapeAttr(opt.value)}"${optionRequiredAttr}${formAttr} /> <span>${escapeHtml(
|
||||
opt.label,
|
||||
)}</span></label>`,
|
||||
);
|
||||
}
|
||||
});
|
||||
parts.push("</div>");
|
||||
parts.push("</div>");
|
||||
|
||||
return parts.join("");
|
||||
@@ -534,9 +737,13 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
|
||||
const sectionStyleAttr =
|
||||
tokens.sectionStyleParts.length > 0 ? ` style="${tokens.sectionStyleParts.join(";")}"` : "";
|
||||
const innerWrapperStyleAttr =
|
||||
tokens.innerWrapperStyleParts.length > 0 ? ` style="${tokens.innerWrapperStyleParts.join(";")}"` : "";
|
||||
const columnsStyleAttr =
|
||||
tokens.columnsStyleParts.length > 0 ? ` style="${tokens.columnsStyleParts.join(";")}"` : "";
|
||||
|
||||
bodyParts.push(
|
||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"><div class="pb-section-columns">`,
|
||||
`<section class="${tokens.sectionClasses}"${sectionStyleAttr}>${tokens.backgroundVideoHtml}<div class="pb-section-inner"${innerWrapperStyleAttr}><div class="pb-section-columns"${columnsStyleAttr}>`,
|
||||
);
|
||||
|
||||
for (const column of columns) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken } from "@/features/auth/authCrypto";
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
import { cachePublicProject } from "@/features/projects/publicCache";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
@@ -108,6 +110,24 @@ export async function POST(request: Request) {
|
||||
},
|
||||
});
|
||||
|
||||
// 퍼블릭 슬러그 페이지(/p/[slug])가 DB 조회 없이도 최신 스냅샷을 바로 렌더링할 수 있도록
|
||||
// 메모리 기반 캐시에 프로젝트 스냅샷을 저장해 둔다.
|
||||
const rawContent = project.contentJson;
|
||||
let contentBlocks: Block[] = [];
|
||||
if (Array.isArray(rawContent)) {
|
||||
contentBlocks = rawContent as unknown as Block[];
|
||||
}
|
||||
|
||||
try {
|
||||
cachePublicProject({
|
||||
slug: project.slug,
|
||||
title: project.title,
|
||||
contentJson: contentBlocks,
|
||||
});
|
||||
} catch {
|
||||
// 캐시 저장 실패는 퍼블릭 렌더링에만 영향을 주므로, API 응답 자체는 정상적으로 계속 진행한다.
|
||||
}
|
||||
|
||||
return NextResponse.json(project, { status: 201 });
|
||||
} catch (error: any) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyAccessToken, decryptJson } from "@/features/auth/authCrypto";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenVersion: number;
|
||||
}
|
||||
|
||||
// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다.
|
||||
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const parts = cookieHeader.split(";");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith("pb_access=")) {
|
||||
const value = trimmed.substring("pb_access=".length);
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다.
|
||||
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
const token = extractTokenFromCookieHeader(cookieHeader);
|
||||
if (!token) return null;
|
||||
|
||||
const payload = await verifyAccessToken(token);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
email: payload.email,
|
||||
tokenVersion: payload.tokenVersion,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/projects/submissions
|
||||
// - 로그인한 사용자의 모든 프로젝트에 대한 폼 제출 내역을 최신순으로 최대 100건 반환한다.
|
||||
// - FormSubmission.userId 가 현재 사용자와 일치하는 레코드만 포함한다.
|
||||
// - payloadJson 과 sensitiveEnc 복호화 결과를 병합해 payload 로 반환한다.
|
||||
export async function GET(request: Request) {
|
||||
const authUser = await getAuthUserFromRequest(request);
|
||||
if (!authUser) {
|
||||
return NextResponse.json(
|
||||
{ message: "폼 제출 내역을 조회하려면 로그인이 필요합니다." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const submissions = await prisma.formSubmission.findMany({
|
||||
where: { userId: authUser.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 100,
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const items = await Promise.all(
|
||||
submissions.map(async (s: any) => {
|
||||
const basePayload = (s.payloadJson ?? {}) as Record<string, unknown>;
|
||||
let sensitive: Record<string, unknown> = {};
|
||||
|
||||
if (s.sensitiveEnc) {
|
||||
try {
|
||||
sensitive = (await decryptJson<Record<string, unknown>>(s.sensitiveEnc)) ?? {};
|
||||
} catch {
|
||||
sensitive = {};
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { ...basePayload, ...sensitive };
|
||||
|
||||
return {
|
||||
id: s.id,
|
||||
createdAt: s.createdAt,
|
||||
projectSlug: s.projectSlug,
|
||||
projectTitle: s.project?.title ?? null,
|
||||
payload,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(items, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
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 에서 요약/프로젝트별 통계를 가져와 카드 형태로 렌더링한다.
|
||||
// - 비로그인(401) 응답 시 로그인 페이지로 리다이렉트하고 에러 메시지를 표시한다.
|
||||
|
||||
interface SummaryStats {
|
||||
totalProjects: number;
|
||||
totalSubmissions: number;
|
||||
todaySubmissions: number;
|
||||
last7DaysSubmissions: number;
|
||||
}
|
||||
|
||||
interface DailySubmissionItem {
|
||||
// 일별 제출 수 그래프를 위한 집계 데이터
|
||||
date: string; // YYYY-MM-DD
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface ProjectStatsItem {
|
||||
projectId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
totalSubmissions: number;
|
||||
lastSubmissionAt: string | null;
|
||||
}
|
||||
|
||||
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[]>([]);
|
||||
const [dailySubmissions, setDailySubmissions] = useState<DailySubmissionItem[]>([]);
|
||||
const [status, setStatus] = useState<PageStatus>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// 마운트 시 대시보드 개요 데이터를 불러온다.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setStatus("loading");
|
||||
setErrorMessage(null);
|
||||
|
||||
const res = await fetch("/api/dashboard/overview");
|
||||
|
||||
if (!res.ok) {
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.status === 401) {
|
||||
setStatus("error");
|
||||
setErrorMessage(t.errorUnauthorized);
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setErrorMessage(t.errorGeneric);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
summaryStats: SummaryStats;
|
||||
projectStats: ProjectStatsItem[];
|
||||
dailySubmissions?: DailySubmissionItem[];
|
||||
};
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setSummary(data.summaryStats ?? null);
|
||||
setProjects(Array.isArray(data.projectStats) ? data.projectStats : []);
|
||||
setDailySubmissions(
|
||||
Array.isArray(data.dailySubmissions) ? data.dailySubmissions : [],
|
||||
);
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage(t.errorGeneric);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const safeSummary: SummaryStats =
|
||||
summary ?? {
|
||||
totalProjects: 0,
|
||||
totalSubmissions: 0,
|
||||
todaySubmissions: 0,
|
||||
last7DaysSubmissions: 0,
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.toggle("dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<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">{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">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
aria-current="page"
|
||||
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>{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>{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>{t.navSubmissions}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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>{t.themeToggleLabel}</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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={() => {
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
void handleLogout();
|
||||
}}
|
||||
>
|
||||
{t.logoutLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 px-6 py-4 space-y-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
|
||||
{status === "error" && errorMessage && (
|
||||
<p className="text-xs text-red-500 dark:text-red-300">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="text-xs text-slate-400">{t.loadingText}</p>
|
||||
)}
|
||||
|
||||
{/* 상단 요약 위젯 영역 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
<div
|
||||
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">
|
||||
{t.summaryTotalProjectsLabel}
|
||||
</span>
|
||||
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalProjects}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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">
|
||||
{t.summaryTotalSubmissionsLabel}
|
||||
</span>
|
||||
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalSubmissions}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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">
|
||||
{t.summaryTodaySubmissionsLabel}
|
||||
</span>
|
||||
<span className="text-2xl font-bold tracking-tight">{safeSummary.todaySubmissions}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
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">
|
||||
{t.summaryLast7DaysSubmissionsLabel}
|
||||
</span>
|
||||
<span className="text-2xl font-bold tracking-tight">{safeSummary.last7DaysSubmissions}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 일별 제출 수 간단 그래프 영역 */}
|
||||
<div
|
||||
data-testid="dashboard-daily-chart"
|
||||
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">{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">{t.chartEmptyLabel}</p>
|
||||
) : (
|
||||
<div className="flex items-end gap-2 text-[10px]">
|
||||
{dailySubmissions.map((item) => (
|
||||
<div
|
||||
key={item.date}
|
||||
data-testid="dashboard-daily-chart-bar"
|
||||
className="flex flex-col items-center gap-1"
|
||||
>
|
||||
<div
|
||||
className="w-6 rounded-t bg-emerald-500/80"
|
||||
style={{ height: `${Math.max(8, item.count * 12)}px` }}
|
||||
/>
|
||||
<span className="text-slate-700 font-semibold dark:text-slate-300">{item.count}</span>
|
||||
<span className="text-slate-500 dark:text-slate-500">{item.date}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 프로젝트 카드 리스트 영역 */}
|
||||
<div className="mt-2">
|
||||
<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">{t.sectionProjectsEmpty}</p>
|
||||
)}
|
||||
|
||||
{projects.length > 0 && (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => {
|
||||
const lastSubmitted = project.lastSubmissionAt
|
||||
? new Date(project.lastSubmissionAt).toLocaleString()
|
||||
: t.projectLatestSubmissionEmpty;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={project.projectId}
|
||||
data-testid="dashboard-project-card"
|
||||
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"
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50">{project.title}</h3>
|
||||
<p className="text-[11px] text-slate-500 font-mono dark:text-slate-400">{project.slug}</p>
|
||||
</div>
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full border border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-transparent dark:text-slate-300">
|
||||
{project.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-700 dark:text-slate-300">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px]">
|
||||
<Link
|
||||
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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -70,7 +75,7 @@ export function EditorCanvas(props: EditorCanvasProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm text-slate-200 border-r border-slate-800 overflow-auto pb-scroll"
|
||||
className="flex-1 p-4 flex flex-col gap-2 text-sm border-r border-slate-800 overflow-auto pb-scroll"
|
||||
data-testid="editor-canvas"
|
||||
style={canvasOuterStyle}
|
||||
onClick={(event) => {
|
||||
@@ -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
|
||||
@@ -118,11 +123,14 @@ interface DragPreviewProps {
|
||||
block: Block | null;
|
||||
}
|
||||
|
||||
function DragPreview({ block }: 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-slate-900/80 px-3 py-2 text-xs text-slate-100 shadow-lg opacity-90 min-w-[160px] max-w-[260px]">
|
||||
<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">
|
||||
{block.type === "text"
|
||||
? "Text"
|
||||
@@ -138,17 +146,21 @@ function DragPreview({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function DragPreview({ block }: DragPreviewProps) {
|
||||
return <EditorCanvasDragPreview block={block} />;
|
||||
}
|
||||
|
||||
@@ -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,18 +13,21 @@ 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;
|
||||
const groupLabelDisplay = checkboxProps.groupLabelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
<h3 className="text-[11px] font-semibold 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-400">그룹 타이틀 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleTypeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -30,15 +35,87 @@ 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-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}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<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>{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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<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>{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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">{m.layoutOptionStacked}</option>
|
||||
<option value="inline">{m.layoutOptionInline}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (checkboxProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label={m.labelGapLabel}
|
||||
unitLabel="(px)"
|
||||
value={typeof checkboxProps.labelGapPx === "number" ? checkboxProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "Tight", value: 4 },
|
||||
{ id: "normal", label: "Normal", value: 8 },
|
||||
{ id: "relaxed", label: "Relaxed", value: 12 },
|
||||
{ id: "extra", label: "Extra", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -58,9 +135,9 @@ 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-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -68,16 +145,16 @@ 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-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -90,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;
|
||||
@@ -135,9 +212,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
})()}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">전송 키</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -149,16 +226,16 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">옵션</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 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-slate-800"
|
||||
onClick={() => {
|
||||
const next = Array.isArray((checkboxProps as any).options)
|
||||
? [...(checkboxProps as any).options]
|
||||
: [];
|
||||
next.push({
|
||||
label: "새 옵션",
|
||||
label: "New option",
|
||||
value: `option_${next.length + 1}`,
|
||||
});
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -166,14 +243,14 @@ 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-400">옵션 이미지 소스</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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={(() => {
|
||||
if (checkboxProps.optionImageSource) return checkboxProps.optionImageSource;
|
||||
const first = (((checkboxProps as any).options ?? []) as any[])[0];
|
||||
@@ -186,17 +263,17 @@ 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>
|
||||
|
||||
{(((checkboxProps as any).options ?? []) as any[]).map((opt, index) => (
|
||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
|
||||
<input
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="라벨"
|
||||
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={m.optionLabelPlaceholder}
|
||||
value={opt.label ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||||
@@ -210,8 +287,8 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="값(value)"
|
||||
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={m.optionValuePlaceholder}
|
||||
value={opt.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||||
@@ -226,7 +303,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
|
||||
className="h-7 w-7 rounded border border-slate-300 bg-white 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 = (((checkboxProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -247,8 +324,8 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
<>
|
||||
{source === "url" && (
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
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={m.optionImageUrlPlaceholder}
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((checkboxProps as any).options ?? []) as any[])];
|
||||
@@ -264,12 +341,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-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;
|
||||
@@ -320,25 +397,15 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(checkboxProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-400">필수 필드</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-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]+)?)/);
|
||||
@@ -355,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]+)?)/);
|
||||
@@ -375,20 +442,20 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
{ id: "tight", label: "Tight", value: 18 },
|
||||
{ id: "normal", label: "Normal", value: 24 },
|
||||
{ id: "loose", label: "Loose", 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]+)?)/);
|
||||
@@ -399,20 +466,20 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
{ id: "tight", label: "Tight", value: -1 },
|
||||
{ id: "normal", label: "Normal", value: 0 },
|
||||
{ id: "wide", label: "Wide", 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-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -420,44 +487,44 @@ 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}
|
||||
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) => {
|
||||
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}
|
||||
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 thin", value: 6 },
|
||||
{ id: "sm", label: "Thin", value: 10 },
|
||||
{ id: "md", label: "Normal", value: 12 },
|
||||
{ id: "lg", label: "Wide", value: 16 },
|
||||
{ id: "xl", label: "Extra wide", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -466,18 +533,18 @@ 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}
|
||||
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 thin", value: 6 },
|
||||
{ id: "sm", label: "Thin", value: 8 },
|
||||
{ id: "md", label: "Normal", value: 10 },
|
||||
{ id: "lg", label: "Wide", value: 12 },
|
||||
{ id: "xl", label: "Extra wide", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -486,17 +553,17 @@ 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}
|
||||
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, {
|
||||
@@ -505,13 +572,13 @@ 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
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -526,13 +593,13 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -547,13 +614,13 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -568,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;
|
||||
@@ -577,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 =
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
|
||||
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"
|
||||
@@ -13,20 +17,74 @@ interface FormControllerPanelProps {
|
||||
export function FormControllerPanel({ block, blocks, selectedBlockId, updateBlock }: FormControllerPanelProps) {
|
||||
if (!selectedBlockId || block.id !== selectedBlockId) return null;
|
||||
|
||||
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);
|
||||
const fieldNames = tokens.fields.map((f) => f.name).filter((name) => !!name);
|
||||
|
||||
if (fieldNames.length === 0) {
|
||||
return (
|
||||
"function doPost(e) {\n" +
|
||||
' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");\n' +
|
||||
" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }\n" +
|
||||
" var params = e.parameter; // x-www-form-urlencoded\n" +
|
||||
" var row = [\n" +
|
||||
' params.name || "",\n' +
|
||||
' params.email || "",\n' +
|
||||
' params.message || "",\n' +
|
||||
" new Date(),\n" +
|
||||
" ];\n" +
|
||||
" sheet.appendRow(row);\n" +
|
||||
" return ContentService\n" +
|
||||
" .createTextOutput(JSON.stringify({ ok: true }))\n" +
|
||||
" .setMimeType(ContentService.MimeType.JSON);\n" +
|
||||
"}"
|
||||
);
|
||||
}
|
||||
|
||||
const headerNames = [...fieldNames, "createdAt"];
|
||||
const rowLines = fieldNames.map((name) => ` params.${name} || "",`);
|
||||
rowLines.push(" new Date(),");
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push("function doPost(e) {");
|
||||
lines.push(' var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("시트1");');
|
||||
lines.push(" if (!sheet) { sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0]; }");
|
||||
lines.push(" var params = e.parameter; // x-www-form-urlencoded");
|
||||
lines.push(" // 시트의 1행 헤더는 다음과 같이 맞춰 주세요:");
|
||||
lines.push(` // ${headerNames.join(", ")}`);
|
||||
lines.push(" var row = [");
|
||||
for (const line of rowLines) {
|
||||
lines.push(line);
|
||||
}
|
||||
lines.push(" ];");
|
||||
lines.push(" sheet.appendRow(row);");
|
||||
lines.push(" return ContentService");
|
||||
lines.push(" .createTextOutput(JSON.stringify({ ok: true }))");
|
||||
lines.push(" .setMimeType(ContentService.MimeType.JSON);");
|
||||
lines.push("}");
|
||||
|
||||
return lines.join("\n");
|
||||
})();
|
||||
|
||||
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">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">전송 대상</span>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.submitTargetLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -34,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>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formProps.submitTarget === "webhook" && (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">Webhook / Google Sheets URL</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.webhookUrlLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: https://script.google.com/macros/s/.../exec"
|
||||
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={m.webhookUrlPlaceholder}
|
||||
value={formProps.destinationUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -55,9 +113,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2">
|
||||
<span className="text-slate-400">전송 포맷</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.payloadFormatLabel}</span>
|
||||
<select
|
||||
className="w-40 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -65,14 +123,14 @@ 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-400">HTTP 메서드</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.httpMethodLabel}</span>
|
||||
<select
|
||||
className="w-28 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -85,10 +143,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="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-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: Bearer xxxxxx"
|
||||
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={m.authTokenPlaceholder}
|
||||
value={formProps.headers?.Authorization ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -101,10 +159,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="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-700 bg-slate-950 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500"
|
||||
placeholder={"예:\nsource=landing\nformId=contact-hero"}
|
||||
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={m.extraParamsPlaceholder}
|
||||
value={Object.entries(formProps.extraParams ?? {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n")}
|
||||
@@ -126,16 +184,26 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
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)}
|
||||
>
|
||||
{m.sheetsGuideButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3 mt-4">
|
||||
<h3 className="text-[11px] font-semibold text-slate-200">폼 레이아웃</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>폼 너비 모드</span>
|
||||
<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>{m.formWidthModeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -143,23 +211,24 @@ 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}
|
||||
max={1200}
|
||||
step={10}
|
||||
presets={[
|
||||
{ id: "sm", label: "좁게", value: 320 },
|
||||
{ id: "md", label: "보통", value: 480 },
|
||||
{ id: "lg", label: "넓게", value: 640 },
|
||||
{ id: "sm", label: m.formFixedWidthPresetNarrow, value: 320 },
|
||||
{ id: "md", label: m.formFixedWidthPresetNormal, value: 480 },
|
||||
{ id: "lg", label: m.formFixedWidthPresetWide, value: 640 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -168,18 +237,19 @@ 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, {
|
||||
@@ -190,12 +260,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-200">폼 메시지</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>성공 메시지</span>
|
||||
<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>{m.successMessageLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: 성공적으로 전송되었습니다."
|
||||
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={m.successMessagePlaceholder}
|
||||
value={formProps.successMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -204,11 +274,11 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>에러 메시지</span>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>{m.errorMessageLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
placeholder="예: 전송 중 오류가 발생했습니다."
|
||||
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={m.errorMessagePlaceholder}
|
||||
value={formProps.errorMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -220,10 +290,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-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" ||
|
||||
@@ -245,15 +315,16 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
: labelText || fieldId;
|
||||
|
||||
const checked = (formProps.fieldIds ?? []).includes(fieldId);
|
||||
const required = (formProps.requiredFieldIds ?? []).includes(fieldId);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={fieldId}
|
||||
className="flex items-center gap-2 text-[11px] text-slate-200"
|
||||
className="flex items-center gap-2 text-[11px] text-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-600 bg-slate-950"
|
||||
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
const current = formProps.fieldIds ?? [];
|
||||
@@ -266,17 +337,35 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<span>{displayLabel}</span>
|
||||
<span className="flex-1 truncate">{displayLabel}</span>
|
||||
<label className="inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-300 bg-white text-slate-900 dark:border-slate-600 dark:bg-slate-900"
|
||||
checked={required}
|
||||
onChange={(e) => {
|
||||
const current = formProps.requiredFieldIds ?? [];
|
||||
const next = e.target.checked
|
||||
? Array.from(new Set([...current, fieldId]))
|
||||
: current.filter((id) => id !== fieldId);
|
||||
|
||||
updateBlock(selectedBlockId, {
|
||||
requiredFieldIds: next,
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<span>{m.requiredLabel}</span>
|
||||
</label>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</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-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="Submit 버튼"
|
||||
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={m.submitButtonAriaLabel}
|
||||
value={formProps.submitButtonId ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -284,7 +373,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="">선택 안 함</option>
|
||||
<option value="">{m.submitButtonNoneOptionLabel}</option>
|
||||
{blocks
|
||||
.filter((b) => b.type === "button")
|
||||
.map((buttonBlock) => {
|
||||
@@ -306,6 +395,40 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSheetsGuide && (
|
||||
<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">{m.sheetsGuideHeading}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-slate-400 hover:text-slate-100"
|
||||
onClick={() => setShowSheetsGuide(false)}
|
||||
aria-label={m.sheetsGuideCloseAriaLabel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
{m.sheetsGuideIntro}
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-[11px] text-slate-400 mt-2">
|
||||
<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">{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
|
||||
value={sheetsScriptExample}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,17 +13,20 @@ 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;
|
||||
const labelDisplay = inputProps.labelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
<h3 className="text-[11px] font-semibold 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-400">라벨 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.labelTypeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -29,14 +34,14 @@ 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-400">필드 라벨</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.fieldLabelLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -45,12 +50,66 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<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}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<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>{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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">{m.layoutOptionStacked}</option>
|
||||
<option value="inline">{m.layoutOptionInline}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{labelDisplay === "visible" && (inputProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label={m.labelGapLabel}
|
||||
unitLabel={m.labelGapUnitLabel}
|
||||
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "Tight", value: 4 },
|
||||
{ id: "normal", label: "Normal", value: 8 },
|
||||
{ id: "relaxed", label: "Relaxed", value: 12 },
|
||||
{ id: "extra", label: "Extra", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{inputProps.labelMode === "image" && (
|
||||
<>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="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-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -60,9 +119,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">라벨 이미지 대체 텍스트</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.labelImageAltLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -74,9 +133,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</>
|
||||
)}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">전송 키</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -86,10 +145,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">필드 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.fieldTypeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="필드 타입"
|
||||
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={m.fieldTypeAria}
|
||||
value={inputProps.inputType ?? "text"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -97,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-400">Placeholder</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.placeholderLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="Placeholder"
|
||||
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={m.placeholderAria}
|
||||
value={inputProps.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -116,23 +175,13 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(inputProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="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-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]+)?)/);
|
||||
@@ -157,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]+)?)/);
|
||||
@@ -169,9 +218,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
{ id: "tight", label: "Tight", value: 18 },
|
||||
{ id: "normal", label: "Normal", value: 24 },
|
||||
{ id: "loose", label: "Loose", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -181,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]+)?)/);
|
||||
@@ -193,9 +242,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
{ id: "tight", label: "Tight", value: -1 },
|
||||
{ id: "normal", label: "Normal", value: 0 },
|
||||
{ id: "wide", label: "Wide", value: 2 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -203,10 +252,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>텍스트 정렬</span>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span>{m.textAlignLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -214,51 +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-400">
|
||||
<span>레이아웃</span>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span>{m.widthModeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
value={inputProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">세로 (기본)</option>
|
||||
<option value="inline">가로 (인라인)</option>
|
||||
</select>
|
||||
</label>
|
||||
{(inputProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label="라벨/필드 간격 (px)"
|
||||
unitLabel="(px)"
|
||||
value={typeof inputProps.labelGapPx === "number" ? inputProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 4 },
|
||||
{ id: "normal", label: "보통", value: 8 },
|
||||
{ id: "relaxed", label: "느슨", value: 12 },
|
||||
{ id: "extra", label: "넓게", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<span>너비</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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={m.widthModeAria}
|
||||
value={inputProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -266,23 +280,22 @@ 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>
|
||||
{(inputProps.widthMode ?? "full") === "fixed" && (
|
||||
<NumericPropertyControl
|
||||
label="필드 고정 너비"
|
||||
unitLabel="(px)"
|
||||
label={m.fixedWidthLabel}
|
||||
unitLabel={m.fixedWidthUnitLabel}
|
||||
value={inputProps.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, {
|
||||
@@ -290,20 +303,19 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<NumericPropertyControl
|
||||
label="필드 가로 패딩 (px)"
|
||||
unitLabel="(px)"
|
||||
label={m.paddingXLabel}
|
||||
unitLabel={m.paddingXUnitLabel}
|
||||
value={typeof inputProps.paddingX === "number" ? inputProps.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 thin", value: 6 },
|
||||
{ id: "sm", label: "Thin", value: 10 },
|
||||
{ id: "md", label: "Normal", value: 12 },
|
||||
{ id: "lg", label: "Wide", value: 16 },
|
||||
{ id: "xl", label: "Extra wide", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -312,18 +324,18 @@ 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}
|
||||
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 thin", value: 6 },
|
||||
{ id: "sm", label: "Thin", value: 8 },
|
||||
{ id: "md", label: "Normal", value: 10 },
|
||||
{ id: "lg", label: "Wide", value: 12 },
|
||||
{ id: "xl", label: "Extra wide", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -332,13 +344,13 @@ 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
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -353,13 +365,13 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -374,13 +386,13 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -395,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;
|
||||
@@ -404,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,18 +13,21 @@ 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;
|
||||
const groupLabelDisplay = radioProps.groupLabelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
<h3 className="text-[11px] font-semibold 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-400">그룹 타이틀 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleTypeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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.groupLabelMode ?? "text"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -30,15 +35,87 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
} 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-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}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
groupLabelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<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>{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={radioProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<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>{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={radioProps.optionLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
optionLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">{m.optionLayoutOptionStacked}</option>
|
||||
<option value="inline">{m.optionLayoutOptionInline}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (radioProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label={m.labelGapLabel}
|
||||
unitLabel="(px)"
|
||||
value={typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "Tight", value: 4 },
|
||||
{ id: "normal", label: "Normal", value: 8 },
|
||||
{ id: "relaxed", label: "Relaxed", value: 12 },
|
||||
{ id: "extra", label: "Extra", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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={radioProps.groupLabel ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -58,9 +135,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
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-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -68,16 +145,16 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
} 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-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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={radioProps.groupLabelImageUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -90,12 +167,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
|
||||
{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;
|
||||
@@ -135,9 +212,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
})()}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">전송 키</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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={radioProps.formFieldName ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -149,16 +226,16 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">옵션</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 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]
|
||||
: [];
|
||||
next.push({
|
||||
label: "새 옵션",
|
||||
label: "New option",
|
||||
value: `option_${next.length + 1}`,
|
||||
});
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -166,14 +243,14 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
옵션 추가
|
||||
{m.addOptionButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">옵션 이미지 소스</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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={(() => {
|
||||
if (radioProps.optionImageSource) return radioProps.optionImageSource;
|
||||
const first = (((radioProps as any).options ?? []) as any[])[0];
|
||||
@@ -186,17 +263,17 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
} 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>
|
||||
|
||||
{(((radioProps as any).options ?? []) as any[]).map((opt, index) => (
|
||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
|
||||
<input
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="라벨"
|
||||
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={m.optionLabelPlaceholder}
|
||||
value={opt.label ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
||||
@@ -210,8 +287,8 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="값(value)"
|
||||
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={m.optionValuePlaceholder}
|
||||
value={opt.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
||||
@@ -226,7 +303,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
|
||||
className="h-7 w-7 rounded border border-slate-300 bg-white 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 = (((radioProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -247,8 +324,8 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<>
|
||||
{source === "url" && (
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
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={m.optionImageUrlPlaceholder}
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((radioProps as any).options ?? []) as any[])];
|
||||
@@ -264,12 +341,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
)}
|
||||
{source === "upload" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="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;
|
||||
@@ -320,25 +397,15 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(radioProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-400">필수 필드</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-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]+)?)/);
|
||||
@@ -361,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]+)?)/);
|
||||
@@ -375,9 +442,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
{ id: "tight", label: "Tight", value: 18 },
|
||||
{ id: "normal", label: "Normal", value: 24 },
|
||||
{ id: "loose", label: "Loose", value: 32 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -385,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]+)?)/);
|
||||
@@ -399,9 +466,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
{ id: "tight", label: "Tight", value: -1 },
|
||||
{ id: "normal", label: "Normal", value: 0 },
|
||||
{ id: "wide", label: "Wide", value: 2 },
|
||||
]}
|
||||
onChangeValue={(v) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -409,10 +476,11 @@ 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-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -420,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, {
|
||||
@@ -445,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, {
|
||||
@@ -465,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, {
|
||||
@@ -485,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, {
|
||||
@@ -504,14 +576,15 @@ 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
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -525,14 +598,15 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -546,14 +620,15 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -567,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;
|
||||
@@ -577,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,18 +13,21 @@ 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;
|
||||
const labelDisplay = selectProps.labelDisplay ?? "visible";
|
||||
|
||||
return (
|
||||
<div className="space-y-3 text-xs">
|
||||
<h3 className="text-[11px] font-semibold 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-400">라벨 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.labelTypeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -30,15 +35,71 @@ 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-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}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelDisplay: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<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>{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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelLayout: e.target.value,
|
||||
} as any)
|
||||
}
|
||||
>
|
||||
<option value="stacked">{m.layoutOptionStacked}</option>
|
||||
<option value="inline">{m.layoutOptionInline}</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{labelDisplay === "visible" && (selectProps.labelLayout ?? "stacked") === "inline" && (
|
||||
<NumericPropertyControl
|
||||
label={m.labelGapLabel}
|
||||
unitLabel="(px)"
|
||||
value={typeof selectProps.labelGapPx === "number" ? selectProps.labelGapPx : 8}
|
||||
min={0}
|
||||
max={80}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "tight", label: "Tight", value: 4 },
|
||||
{ id: "normal", label: "Normal", value: 8 },
|
||||
{ id: "relaxed", label: "Relaxed", value: 12 },
|
||||
{ id: "extra", label: "Extra", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
labelGapPx: v,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.fieldLabelLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -49,9 +110,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">전송 키</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</span>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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 ?? ""}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -63,16 +124,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">옵션</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-0.5 text-[10px] text-slate-200 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-slate-800"
|
||||
onClick={() => {
|
||||
const next = Array.isArray((selectProps as any).options)
|
||||
? [...(selectProps as any).options]
|
||||
: [];
|
||||
next.push({
|
||||
label: "새 옵션",
|
||||
label: "New option",
|
||||
value: `option_${next.length + 1}`,
|
||||
});
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -80,16 +141,16 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
옵션 추가
|
||||
{m.addOptionButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(((selectProps as any).options ?? []) as any[]).map((opt, index) => (
|
||||
<div key={opt.value ?? index} className="space-y-1 rounded border border-slate-800 p-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
|
||||
<input
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="라벨"
|
||||
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={m.optionLabelPlaceholder}
|
||||
value={opt.label ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((selectProps as any).options ?? []) as any[])];
|
||||
@@ -103,8 +164,8 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
placeholder="값(value)"
|
||||
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={m.optionValuePlaceholder}
|
||||
value={opt.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const next = [...(((selectProps as any).options ?? []) as any[])];
|
||||
@@ -119,7 +180,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 rounded border border-slate-700 bg-slate-950 text-[10px] text-slate-300 hover:bg-red-900/60 hover:border-red-700"
|
||||
className="h-7 w-7 rounded border border-slate-300 bg-white 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 = (((selectProps as any).options ?? []) as any[]).filter((_, i) => i !== index);
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -136,23 +197,13 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
</div>
|
||||
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border border-slate-600 bg-slate-900"
|
||||
checked={Boolean(selectProps.required)}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
required: e.target.checked,
|
||||
} as any)
|
||||
}
|
||||
/>
|
||||
<span className="text-slate-400">필수 필드</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-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]+)?)/);
|
||||
@@ -169,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]+)?)/);
|
||||
@@ -189,20 +240,20 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
max={60}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "tight", label: "타이트", value: 18 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "loose", label: "루즈", value: 32 },
|
||||
{ id: "tight", label: "Tight", value: 18 },
|
||||
{ id: "normal", label: "Normal", value: 24 },
|
||||
{ id: "loose", label: "Loose", 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]+)?)/);
|
||||
@@ -213,20 +264,20 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
max={20}
|
||||
step={0.5}
|
||||
presets={[
|
||||
{ id: "tight", label: "좁게", value: -1 },
|
||||
{ id: "normal", label: "보통", value: 0 },
|
||||
{ id: "wide", label: "넓게", value: 2 },
|
||||
{ id: "tight", label: "Tight", value: -1 },
|
||||
{ id: "normal", label: "Normal", value: 0 },
|
||||
{ id: "wide", label: "Wide", 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>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
<span>{m.widthModeLabel}</span>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -234,44 +285,44 @@ export function FormSelectPropertiesPanel({ 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</option>
|
||||
<option value="fixed">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}
|
||||
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) => {
|
||||
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}
|
||||
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 thin", value: 6 },
|
||||
{ id: "sm", label: "Thin", value: 10 },
|
||||
{ id: "md", label: "Normal", value: 12 },
|
||||
{ id: "lg", label: "Wide", value: 16 },
|
||||
{ id: "xl", label: "Extra wide", value: 20 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -280,18 +331,18 @@ 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}
|
||||
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 thin", value: 6 },
|
||||
{ id: "sm", label: "Thin", value: 8 },
|
||||
{ id: "md", label: "Normal", value: 10 },
|
||||
{ id: "lg", label: "Wide", value: 12 },
|
||||
{ id: "xl", label: "Extra wide", value: 16 },
|
||||
]}
|
||||
onChangeValue={(v) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -300,13 +351,13 @@ 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
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -321,13 +372,13 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -342,13 +393,13 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -363,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;
|
||||
@@ -372,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 =
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../../styles/editor.css";
|
||||
|
||||
export default function EditorLayout({ children }: { children: ReactNode }) {
|
||||
// 에디터 전용 전역 스타일(editor.css)을 로드하기 위한 중첩 레이아웃.
|
||||
// 실제 마크업 구조는 page.tsx 에서 정의하며, 여기서는 children 을 그대로 반환한다.
|
||||
return children;
|
||||
}
|
||||
+407
-123
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -46,43 +50,58 @@ export function BlocksSidebar() {
|
||||
const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection);
|
||||
|
||||
// 버튼 핸들러를 useCallback으로 래핑해 불필요한 재생성을 줄인다.
|
||||
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
|
||||
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
|
||||
const handleAddText = useCallback(() => addTextBlock(locale), [addTextBlock, locale]);
|
||||
const handleAddButton = useCallback(() => addButtonBlock(locale), [addButtonBlock, locale]);
|
||||
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
|
||||
const handleAddVideo = useCallback(() => addVideoBlock(), [addVideoBlock]);
|
||||
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
|
||||
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
|
||||
const handleAddList = useCallback(() => addListBlock(locale), [addListBlock, locale]);
|
||||
const handleAddSection = useCallback(() => addSectionBlock(), [addSectionBlock]);
|
||||
|
||||
const handleAddFormBlock = useCallback(() => addFormBlock(), [addFormBlock]);
|
||||
const handleAddFormInput = useCallback(() => addFormInputBlock(), [addFormInputBlock]);
|
||||
const handleAddFormSelect = useCallback(() => addFormSelectBlock(), [addFormSelectBlock]);
|
||||
const handleAddFormRadio = useCallback(() => addFormRadioBlock(), [addFormRadioBlock]);
|
||||
const handleAddFormCheckbox = useCallback(() => addFormCheckboxBlock(), [addFormCheckboxBlock]);
|
||||
const handleAddFormBlock = useCallback(() => addFormBlock(locale), [addFormBlock, locale]);
|
||||
const handleAddFormInput = useCallback(() => addFormInputBlock(locale), [addFormInputBlock, locale]);
|
||||
const handleAddFormSelect = useCallback(() => addFormSelectBlock(locale), [addFormSelectBlock, locale]);
|
||||
const handleAddFormRadio = useCallback(() => addFormRadioBlock(locale), [addFormRadioBlock, locale]);
|
||||
const handleAddFormCheckbox = useCallback(
|
||||
() => addFormCheckboxBlock(locale),
|
||||
[addFormCheckboxBlock, locale],
|
||||
);
|
||||
|
||||
const handleAddHeroTemplate = useCallback(
|
||||
() => addHeroTemplateSection(),
|
||||
[addHeroTemplateSection],
|
||||
() => addHeroTemplateSection(locale),
|
||||
[addHeroTemplateSection, locale],
|
||||
);
|
||||
const handleAddFeaturesTemplate = useCallback(
|
||||
() => addFeaturesTemplateSection(),
|
||||
[addFeaturesTemplateSection],
|
||||
() => addFeaturesTemplateSection(locale),
|
||||
[addFeaturesTemplateSection, locale],
|
||||
);
|
||||
const handleAddCtaTemplate = useCallback(
|
||||
() => addCtaTemplateSection(locale),
|
||||
[addCtaTemplateSection, locale],
|
||||
);
|
||||
const handleAddFaqTemplate = useCallback(
|
||||
() => addFaqTemplateSection(locale),
|
||||
[addFaqTemplateSection, locale],
|
||||
);
|
||||
const handleAddCtaTemplate = useCallback(() => addCtaTemplateSection(), [addCtaTemplateSection]);
|
||||
const handleAddFaqTemplate = useCallback(() => addFaqTemplateSection(), [addFaqTemplateSection]);
|
||||
const handleAddPricingTemplate = useCallback(
|
||||
() => addPricingTemplateSection(),
|
||||
[addPricingTemplateSection],
|
||||
() => addPricingTemplateSection(locale),
|
||||
[addPricingTemplateSection, locale],
|
||||
);
|
||||
const handleAddTestimonialsTemplate = useCallback(
|
||||
() => addTestimonialsTemplateSection(),
|
||||
[addTestimonialsTemplateSection],
|
||||
() => addTestimonialsTemplateSection(locale),
|
||||
[addTestimonialsTemplateSection, locale],
|
||||
);
|
||||
const handleAddBlogTemplate = useCallback(
|
||||
() => addBlogTemplateSection(locale),
|
||||
[addBlogTemplateSection, locale],
|
||||
);
|
||||
const handleAddTeamTemplate = useCallback(
|
||||
() => addTeamTemplateSection(locale),
|
||||
[addTeamTemplateSection, locale],
|
||||
);
|
||||
const handleAddBlogTemplate = useCallback(() => addBlogTemplateSection(), [addBlogTemplateSection]);
|
||||
const handleAddTeamTemplate = useCallback(() => addTeamTemplateSection(), [addTeamTemplateSection]);
|
||||
const handleAddFooterTemplate = useCallback(
|
||||
() => addFooterTemplateSection(),
|
||||
[addFooterTemplateSection],
|
||||
() => addFooterTemplateSection(locale),
|
||||
[addFooterTemplateSection, locale],
|
||||
);
|
||||
|
||||
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
|
||||
@@ -90,8 +109,11 @@ export function BlocksSidebar() {
|
||||
const [isTemplatesOpen, setIsTemplatesOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<aside className="w-60 border-r border-slate-800 p-4 text-sm space-y-3 overflow-y-auto pb-scroll bg-slate-950/40">
|
||||
<h2 className="font-medium flex items-center gap-2 text-slate-200">
|
||||
<aside
|
||||
data-testid="blocks-sidebar"
|
||||
className="w-60 border-r border-slate-200 bg-white p-4 text-sm space-y-3 overflow-y-auto pb-scroll dark:border-slate-800 dark:bg-slate-950/40"
|
||||
>
|
||||
<h2 className="font-medium flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
@@ -100,7 +122,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>
|
||||
@@ -108,79 +130,79 @@ export function BlocksSidebar() {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddText}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddButton}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddImage}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddVideo}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddDivider}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddList}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddSection}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-2">
|
||||
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
|
||||
<div className="pt-3 border-t border-slate-200 mt-3 space-y-2 dark:border-slate-800">
|
||||
<h3 className="text-[11px] font-medium text-slate-800 flex items-center gap-2 dark:text-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
@@ -189,7 +211,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>
|
||||
@@ -197,60 +219,60 @@ export function BlocksSidebar() {
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border border-emerald-700 bg-emerald-950 px-3 py-2 text-left text-xs text-emerald-100 hover:bg-emerald-900"
|
||||
className="w-full rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-left text-xs text-emerald-900 hover:bg-emerald-100 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-100 dark:hover:bg-emerald-900"
|
||||
onClick={handleAddFormBlock}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddFormInput}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddFormSelect}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddFormRadio}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
className="w-full 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={handleAddFormCheckbox}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-slate-800 mt-3 space-y-3">
|
||||
<h3 className="text-[11px] font-medium text-slate-300 flex items-center gap-2">
|
||||
<div className="pt-3 border-t border-slate-200 mt-3 space-y-3 dark:border-slate-800">
|
||||
<h3 className="text-[11px] font-medium text-slate-800 flex items-center gap-2 dark:text-slate-200">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
@@ -259,7 +281,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>
|
||||
@@ -267,20 +289,20 @@ export function BlocksSidebar() {
|
||||
{isTemplatesOpen && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-semibold 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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-[2px]">
|
||||
<div className="h-[2px] w-3/4 bg-slate-700/70" />
|
||||
@@ -289,23 +311,21 @@ 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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex h-full w-full items-center gap-[2px]">
|
||||
<div className="flex-1 flex flex-col justify-center gap-[1px]">
|
||||
@@ -316,26 +336,26 @@ 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-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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
@@ -351,21 +371,21 @@ 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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 items-start gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="w-2 flex flex-col gap-[1px]">
|
||||
<div className="h-[2px] w-full bg-slate-700/70" />
|
||||
@@ -377,21 +397,21 @@ 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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center justify-end gap-[1px] border border-slate-700/30 rounded-[1px]">
|
||||
<div className="h-[1px] w-1/2 bg-slate-700/70" />
|
||||
@@ -408,21 +428,21 @@ 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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
<div className="h-2 w-full bg-slate-600/50 rounded-[1px]" />
|
||||
@@ -441,26 +461,26 @@ 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-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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 flex flex-col justify-between border border-slate-700/30 rounded-[1px] p-[1px]">
|
||||
<div className="h-[1px] w-full bg-slate-700/50" />
|
||||
@@ -476,21 +496,21 @@ 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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 items-stretch gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center gap-[1px]">
|
||||
<div className="h-2 w-2 rounded-full bg-slate-600/80" />
|
||||
@@ -509,26 +529,26 @@ 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-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-800 bg-slate-950/50 p-2 space-y-1">
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-3 py-2 text-left text-xs hover:bg-slate-800"
|
||||
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"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-700 bg-slate-900 p-[2px]"
|
||||
className="shrink-0 flex h-6 w-10 items-center justify-between gap-[2px] rounded border border-slate-300 bg-slate-100 p-[2px] dark:border-slate-700 dark:bg-slate-900"
|
||||
>
|
||||
<div className="flex-1 h-[2px] bg-slate-700/70" />
|
||||
<div className="flex-1 flex flex-col gap-[1px]">
|
||||
@@ -538,7 +558,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>
|
||||
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 텍스트"
|
||||
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={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-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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="버튼 이미지 소스"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 이미지 URL"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 이미지 대체 텍스트"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 이미지 위치"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 링크"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 정렬"
|
||||
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={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,13 +259,13 @@ 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.startsWith("#")
|
||||
buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== ""
|
||||
? buttonProps.textColorCustom
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -278,31 +282,31 @@ 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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 스타일"
|
||||
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={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.startsWith("#")
|
||||
buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== ""
|
||||
? buttonProps.fillColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -319,13 +323,13 @@ 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.startsWith("#")
|
||||
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== ""
|
||||
? buttonProps.strokeColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="버튼 너비 모드"
|
||||
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={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]+)?)/);
|
||||
@@ -472,15 +476,15 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
}
|
||||
return 0;
|
||||
})()}
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.1}
|
||||
min={-32}
|
||||
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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="구분선 정렬"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="구분선 두께"
|
||||
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={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-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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="구분선 길이 모드"
|
||||
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={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,13 +96,13 @@ 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
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#64748b"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { colorHex: hex } as any);
|
||||
@@ -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);
|
||||
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="이미지 소스"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="이미지 URL"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="대체 텍스트"
|
||||
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={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-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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="이미지 정렬"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="이미지 너비 모드"
|
||||
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={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 범위를 토큰으로 매핑한다.
|
||||
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="리스트 아이템들"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="리스트 정렬"
|
||||
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={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-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,13 +154,15 @@ export function ListPropertiesPanel({
|
||||
|
||||
{/* 텍스트 색상 */}
|
||||
<ColorPickerField
|
||||
label="텍스트 색상"
|
||||
ariaLabelColorInput="리스트 텍스트 색상 피커"
|
||||
ariaLabelHexInput="리스트 텍스트 색상 HEX"
|
||||
label={m.textColorLabel}
|
||||
ariaLabelColorInput={m.textColorPickerAria}
|
||||
ariaLabelHexInput={m.textColorHexAria}
|
||||
// textColorCustom 이 비어 있으면 커스텀 색상을 사용하지 않고, "없음" 상태로 취급한다.
|
||||
// 이 경우 HEX 인풋은 빈 문자열을 유지하고, 팔레트 라벨은 "없음"으로 표시된다.
|
||||
value={
|
||||
listProps.textColorCustom && listProps.textColorCustom.trim() !== ""
|
||||
? listProps.textColorCustom
|
||||
: "#e5e7eb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { textColorCustom: hex } as any);
|
||||
@@ -168,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);
|
||||
@@ -180,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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="리스트 불릿 스타일"
|
||||
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={m.bulletStyleAria}
|
||||
value={listProps.bulletStyle ?? "disc"}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value as NonNullable<ListBlockProps["bulletStyle"]>;
|
||||
@@ -201,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;
|
||||
@@ -228,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 });
|
||||
@@ -38,15 +43,15 @@ export function ProjectPropertiesPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-xs text-slate-200">
|
||||
<h3 className="text-sm font-medium text-slate-100">프로젝트 설정</h3>
|
||||
<div className="space-y-4 text-xs text-slate-900 dark:text-slate-200">
|
||||
<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-400">프로젝트 제목</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.projectTitleLabel}</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="프로젝트 제목"
|
||||
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={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-400">프로젝트 주소 (slug)</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.projectSlugLabel}</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="프로젝트 주소 (slug)"
|
||||
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={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,86 +110,86 @@ export function ProjectPropertiesPanel() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-3">
|
||||
<h4 className="text-[11px] font-semibold 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-400">SEO 타이틀</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.seoTitleLabel}</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="SEO 타이틀"
|
||||
placeholder={projectConfig.title || "페이지 제목"}
|
||||
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={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-400">메타 디스크립션</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.seoDescriptionLabel}</span>
|
||||
<textarea
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500 min-h-[56px]"
|
||||
aria-label="메타 디스크립션"
|
||||
placeholder="검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요."
|
||||
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={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-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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="OG/Twitter 이미지 URL"
|
||||
placeholder="예: https://example.com/og-image.png"
|
||||
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={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-400">Canonical URL</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.seoCanonicalUrlLabel}</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="Canonical URL"
|
||||
placeholder="예: https://example.com/landing"
|
||||
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={m.seoCanonicalUrlAria}
|
||||
placeholder={m.seoCanonicalUrlPlaceholder}
|
||||
value={projectConfig.seoCanonicalUrl ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ seoCanonicalUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-[11px] text-slate-300">
|
||||
<label className="flex items-center gap-2 text-[11px] text-slate-600 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-slate-600 bg-slate-900"
|
||||
aria-label="검색 엔진에 노출하지 않기 (noindex)"
|
||||
className="h-3 w-3 rounded border-slate-300 bg-white text-sky-600 dark:border-slate-600 dark:bg-slate-900"
|
||||
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-400">페이지 head HTML</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.headHtmlLabel}</span>
|
||||
<textarea
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500 min-h-[72px]"
|
||||
aria-label="페이지 head HTML"
|
||||
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={m.headHtmlAria}
|
||||
value={projectConfig.headHtml ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ headHtml: e.target.value })}
|
||||
placeholder="예: <meta name="description" content="..." />"
|
||||
placeholder={m.headHtmlPlaceholder}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">추적 스크립트</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">{m.trackingScriptLabel}</span>
|
||||
<textarea
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] font-mono outline-none focus:border-sky-500 min-h-[72px]"
|
||||
aria-label="추적 스크립트"
|
||||
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={m.trackingScriptAria}
|
||||
value={projectConfig.trackingScript ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ trackingScript: e.target.value })}
|
||||
placeholder="예: <script>/* GA, Pixel 코드 */</script>"
|
||||
placeholder={m.trackingScriptPlaceholder}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -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,644 +74,79 @@ 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;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
data-testid="properties-sidebar"
|
||||
className="w-80 p-4 text-sm border-l border-slate-800 flex flex-col gap-4 overflow-auto pb-scroll bg-slate-950/40"
|
||||
className="w-80 p-4 text-sm border-l border-slate-200 bg-white flex flex-col gap-4 overflow-auto pb-scroll dark:border-slate-800 dark:bg-slate-950/40"
|
||||
onKeyDownCapture={(e) => {
|
||||
// 속성 패널 안에서 발생한 키 입력은 에디터 단축키/텍스트 블록 편집으로 전달되지 않도록 막는다.
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<h2 className="font-medium mb-2 flex items-center gap-2 text-slate-200">
|
||||
<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">
|
||||
<div className="flex gap-2 text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-red-900/60 hover:border-red-700"
|
||||
className="flex-1 rounded border border-slate-200 bg-slate-50 px-2 py-1 text-slate-900 hover:bg-red-50 hover:border-red-300 hover:text-red-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
|
||||
onClick={() => removeBlock(selectedBlockId)}
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
className="flex-1 rounded border border-slate-200 bg-slate-50 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={() => duplicateBlock(selectedBlockId)}
|
||||
>
|
||||
<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>
|
||||
<div className="flex justify-end text-[11px]">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-200 bg-slate-50 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={() => setHelpOpen(true)}
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" aria-hidden="true" />
|
||||
<span>HELP</span>
|
||||
<span>{m.actionHelp}</span>
|
||||
</button>
|
||||
</div>
|
||||
{(() => {
|
||||
@@ -864,26 +303,26 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
<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-2">
|
||||
<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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 소스"
|
||||
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={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.backgroundImageSourceOptionNone}</option>
|
||||
<option value="url">{m.backgroundImageSourceOptionUrl}</option>
|
||||
<option value="upload">{m.backgroundImageSourceOptionUpload}</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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 URL"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 위치 모드"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 크기"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 위치"
|
||||
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={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"
|
||||
@@ -225,9 +229,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
max={100}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "left", label: "왼쪽", value: 0 },
|
||||
{ id: "center", label: "가운데", value: 50 },
|
||||
{ id: "right", label: "오른쪽", value: 100 },
|
||||
{ id: "left", label: m.backgroundImagePositionOptionLeft, value: 0 },
|
||||
{ id: "center", label: m.backgroundImagePositionOptionCenter, value: 50 },
|
||||
{ id: "right", label: m.backgroundImagePositionOptionRight, value: 100 },
|
||||
]}
|
||||
onChangeValue={(next) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -238,7 +242,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
/>
|
||||
|
||||
<NumericPropertyControl
|
||||
label="배경 이미지 세로 위치"
|
||||
label={m.backgroundImagePositionYLabel}
|
||||
unitLabel="(%)"
|
||||
value={
|
||||
typeof sectionProps.backgroundImagePositionYPercent === "number"
|
||||
@@ -249,9 +253,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
max={100}
|
||||
step={1}
|
||||
presets={[
|
||||
{ id: "top", label: "위", value: 0 },
|
||||
{ id: "center", label: "가운데", value: 50 },
|
||||
{ id: "bottom", label: "아래", value: 100 },
|
||||
{ id: "top", label: m.backgroundImagePositionOptionTop, value: 0 },
|
||||
{ id: "center", label: m.backgroundImagePositionOptionCenter, value: 50 },
|
||||
{ id: "bottom", label: m.backgroundImagePositionOptionBottom, value: 100 },
|
||||
]}
|
||||
onChangeValue={(next) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 이미지 반복"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="배경 비디오 소스"
|
||||
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={m.backgroundVideoSourceAria}
|
||||
value={backgroundVideoSource}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as "none" | "url" | "upload";
|
||||
@@ -313,18 +317,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>
|
||||
|
||||
{backgroundVideoSource === "url" && (
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 URL</span>
|
||||
<span>{m.backgroundVideoUrlLabel}</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="배경 비디오 URL"
|
||||
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={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;
|
||||
@@ -383,21 +387,21 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 세로 패딩 슬라이더 (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}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "x-tight", label: "매우 좁게", value: 16 },
|
||||
{ id: "tight", label: "좁게", value: 32 },
|
||||
{ id: "normal", label: "보통", value: 48 },
|
||||
{ id: "relaxed", label: "넓게", value: 64 },
|
||||
{ id: "x-relaxed", label: "아주 넓게", value: 80 },
|
||||
{ id: "x-tight", label: m.paddingYPresetExtraTight, value: 16 },
|
||||
{ id: "tight", label: m.paddingYPresetTight, value: 32 },
|
||||
{ id: "normal", label: m.paddingYPresetNormal, value: 48 },
|
||||
{ id: "relaxed", label: m.paddingYPresetRelaxed, value: 64 },
|
||||
{ id: "x-relaxed", label: m.paddingYPresetExtraRelaxed, value: 80 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 48;
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="섹션 컬럼 레이아웃"
|
||||
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={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>
|
||||
|
||||
@@ -678,20 +682,20 @@ 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}
|
||||
step={16}
|
||||
presets={[
|
||||
{ id: "x-narrow", label: "매우 좁게", value: 640 },
|
||||
{ id: "narrow", label: "좁게", value: 800 },
|
||||
{ id: "normal", label: "보통", value: 960 },
|
||||
{ id: "wide", label: "넓게", value: 1200 },
|
||||
{ id: "x-wide", label: "아주 넓게", value: 1440 },
|
||||
{ id: "x-narrow", label: m.maxWidthPresetExtraNarrow, value: 640 },
|
||||
{ id: "narrow", label: m.maxWidthPresetNarrow, value: 800 },
|
||||
{ id: "normal", label: m.maxWidthPresetNormal, value: 960 },
|
||||
{ id: "wide", label: m.maxWidthPresetWide, value: 1200 },
|
||||
{ id: "x-wide", label: m.maxWidthPresetExtraWide, value: 1440 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 960;
|
||||
@@ -699,21 +703,21 @@ 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}
|
||||
step={2}
|
||||
presets={[
|
||||
{ id: "zero", label: "0", value: 0 },
|
||||
{ id: "tight", label: "좁게", value: 16 },
|
||||
{ id: "normal", label: "보통", value: 24 },
|
||||
{ id: "relaxed", label: "넓게", value: 32 },
|
||||
{ id: "x-relaxed", label: "아주 넓게", value: 48 },
|
||||
{ id: "max", label: "최대", value: 64 },
|
||||
{ id: "zero", label: m.gapXPresetZero, value: 0 },
|
||||
{ id: "tight", label: m.gapXPresetTight, value: 16 },
|
||||
{ id: "normal", label: m.gapXPresetNormal, value: 24 },
|
||||
{ id: "relaxed", label: m.gapXPresetRelaxed, value: 32 },
|
||||
{ id: "x-relaxed", label: m.gapXPresetExtraRelaxed, value: 48 },
|
||||
{ id: "max", label: m.gapXPresetMax, value: 64 },
|
||||
]}
|
||||
onChangeValue={(next) => {
|
||||
const safe = Number.isFinite(next) ? next : 24;
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="섹션 컬럼 세로 정렬"
|
||||
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={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>
|
||||
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="선택한 텍스트 블록 내용"
|
||||
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={m.selectedTextAria}
|
||||
value={textProps.text}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
@@ -114,33 +118,33 @@ 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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="정렬"
|
||||
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={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"
|
||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
||||
textProps.underline
|
||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
||||
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -148,14 +152,14 @@ export function TextPropertiesPanel({
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
밑줄
|
||||
{m.underlineLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
||||
textProps.strike
|
||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
||||
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -163,14 +167,14 @@ export function TextPropertiesPanel({
|
||||
} as any);
|
||||
}}
|
||||
>
|
||||
가운데줄
|
||||
{m.strikeLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`px-2 py-0.5 rounded border text-[11px] ${
|
||||
textProps.italic
|
||||
? "border-sky-500 bg-sky-900/40 text-sky-100"
|
||||
: "border-slate-700 bg-slate-900 text-slate-300"
|
||||
: "border-slate-300 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
}`}
|
||||
onClick={() => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="글자 간격 프리셋"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="최대 너비 프리셋"
|
||||
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={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/);
|
||||
@@ -502,9 +506,9 @@ export function TextPropertiesPanel({
|
||||
/>
|
||||
</div>
|
||||
<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="최대 너비 커스텀"
|
||||
placeholder="예: 600px, 40rem, 80%, 60ch"
|
||||
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={m.maxWidthCustomAria}
|
||||
placeholder={m.maxWidthPlaceholder}
|
||||
value={textProps.maxWidthCustom ?? ""}
|
||||
onChange={(e) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
|
||||
@@ -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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 소스"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="비디오 URL"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
aria-label="포스터 이미지 URL"
|
||||
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={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-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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 정렬"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="비디오 너비 모드"
|
||||
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={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-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
aria-label="화면 비율"
|
||||
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={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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { EditorPricingTemplateMessages } from "@/features/i18n/messages/editorTemplates";
|
||||
|
||||
export function createPricingTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
createId: () => string;
|
||||
messages: EditorPricingTemplateMessages;
|
||||
}): { blocks: Block[]; lastSelectedId: string } {
|
||||
const { sectionId, createId } = opts;
|
||||
const { sectionId, createId, messages } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 4 },
|
||||
@@ -33,11 +35,7 @@ export function createPricingTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const planDefinitions: Array<{ name: string; price: string; popular?: boolean }> = [
|
||||
{ name: "Basic", price: "₩9,900/월" },
|
||||
{ name: "Pro", price: "₩29,900/월", popular: true },
|
||||
{ name: "Enterprise", price: "문의" },
|
||||
];
|
||||
const planDefinitions = messages.plans;
|
||||
|
||||
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const plan = planDefinitions[index] ?? planDefinitions[0];
|
||||
@@ -96,7 +94,7 @@ export function createPricingTemplateBlocks(opts: {
|
||||
// Features list (simple text for now)
|
||||
const featuresId = createId();
|
||||
const featuresProps: TextBlockProps = {
|
||||
text: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
|
||||
text: messages.featuresText,
|
||||
align: "center",
|
||||
size: "sm",
|
||||
color: "muted",
|
||||
@@ -112,7 +110,7 @@ export function createPricingTemplateBlocks(opts: {
|
||||
// Button
|
||||
const buttonId = createId();
|
||||
const buttonProps: ButtonBlockProps = {
|
||||
label: isPopular ? "시작하기" : "선택하기",
|
||||
label: isPopular ? messages.primaryButtonLabel : messages.secondaryButtonLabel,
|
||||
href: "#",
|
||||
align: "center",
|
||||
size: "md",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
|
||||
import type { EditorTestimonialsTemplateMessages } from "@/features/i18n/messages/editorTemplates";
|
||||
|
||||
export function createTestimonialsTemplateBlocks(opts: {
|
||||
sectionId: string;
|
||||
createId: () => string;
|
||||
messages: EditorTestimonialsTemplateMessages;
|
||||
}): { blocks: Block[]; lastSelectedId: string } {
|
||||
const { sectionId, createId } = opts;
|
||||
const { sectionId, createId, messages } = opts;
|
||||
|
||||
const columns: SectionBlockProps["columns"] = [
|
||||
{ id: `${sectionId}_col_1`, span: 4 },
|
||||
@@ -33,11 +35,7 @@ export function createTestimonialsTemplateBlocks(opts: {
|
||||
columnId: null,
|
||||
};
|
||||
|
||||
const testimonialDefinitions: Array<{ body: string; author: string }> = [
|
||||
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
|
||||
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
|
||||
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
|
||||
];
|
||||
const testimonialDefinitions = messages.items;
|
||||
|
||||
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
|
||||
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
|
||||
|
||||
+19
-3
@@ -1,17 +1,33 @@
|
||||
import "../styles/globals.css";
|
||||
import "../styles/builder.css";
|
||||
import type { ReactNode } from "react";
|
||||
import { headers, cookies } 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 cookieStore = await cookies();
|
||||
const cookieLocale = cookieStore.get("pb-locale")?.value;
|
||||
|
||||
const headerList = await headers();
|
||||
const acceptLanguage = headerList.get("accept-language");
|
||||
|
||||
const localeFromCookie = cookieLocale === "en" || cookieLocale === "ko" ? cookieLocale : null;
|
||||
const locale = (localeFromCookie ?? resolveLocaleFromAcceptLanguage(acceptLanguage ?? undefined)) ?? DEFAULT_LOCALE;
|
||||
|
||||
return (
|
||||
<html lang="ko" suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-slate-950 text-slate-50">
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
||||
<LocaleProvider initialLocale={locale}>
|
||||
{children}
|
||||
<LocaleSwitcher />
|
||||
</LocaleProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+51
-21
@@ -3,6 +3,9 @@
|
||||
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 으로 요청을 전송한다.
|
||||
@@ -11,11 +14,27 @@ import Link from "next/link";
|
||||
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);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.toggle("dark");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -23,7 +42,7 @@ export default function LoginPage() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!cancelled && res.ok) {
|
||||
router.push("/projects");
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -53,56 +72,66 @@ 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;
|
||||
}
|
||||
|
||||
// 성공 시에는 /projects 로 이동한다.
|
||||
router.push("/projects");
|
||||
// 성공 시에는 /dashboard 로 이동한다.
|
||||
router.push("/dashboard");
|
||||
} catch {
|
||||
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setError(t.errorNetwork);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-slate-950 text-slate-50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
|
||||
<h1 className="text-lg font-semibold mb-1">로그인</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">프로젝트를 관리하려면 먼저 계정으로 로그인하세요.</p>
|
||||
<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">{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>{t.themeToggleLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
<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-200">
|
||||
이메일
|
||||
<label htmlFor="email" className="block text-slate-800 dark:text-slate-200">
|
||||
{t.emailLabel}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="block text-slate-200">
|
||||
비밀번호
|
||||
<label htmlFor="password" className="block text-slate-800 dark:text-slate-200">
|
||||
{t.passwordLabel}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
@@ -113,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-400">
|
||||
아직 계정이 없다면
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPageRenderer";
|
||||
|
||||
interface PublicProjectPageClientProps {
|
||||
html: string;
|
||||
}
|
||||
|
||||
export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) {
|
||||
const m = getPublicPageRendererMessages(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||
|
||||
let toastTimeout: number | null = null;
|
||||
|
||||
const showToast = (message: string) => {
|
||||
setToastMessage(message);
|
||||
if (toastTimeout !== null) {
|
||||
window.clearTimeout(toastTimeout);
|
||||
}
|
||||
toastTimeout = window.setTimeout(() => {
|
||||
setToastMessage(null);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const forms = document.querySelectorAll<HTMLFormElement>(
|
||||
'form.pb-form-controller, form[action="/api/forms/submit"]',
|
||||
);
|
||||
if (!forms || forms.length === 0) return;
|
||||
|
||||
forms.forEach((form) => {
|
||||
if (form.dataset.pbInitialized === "1") return;
|
||||
form.dataset.pbInitialized = "1";
|
||||
|
||||
form.addEventListener("submit", (event) => {
|
||||
if (!form) return;
|
||||
if (event && typeof event.preventDefault === "function") {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
const configInput = form.querySelector<HTMLInputElement>('input[name="__config"]');
|
||||
let config: any = null;
|
||||
if (configInput && configInput.value) {
|
||||
try {
|
||||
config = JSON.parse(configInput.value);
|
||||
} catch {
|
||||
config = null;
|
||||
}
|
||||
}
|
||||
|
||||
const action = form.getAttribute("action") || "/api/forms/submit";
|
||||
|
||||
fetch(action, { method: "POST", body: formData })
|
||||
.then((res) => res.json().catch(() => ({})))
|
||||
.then((data: any) => {
|
||||
const ok = data && data.ok;
|
||||
const message = data && data.message;
|
||||
if (ok) {
|
||||
const success = message || (config && config.successMessage) || m.submitSuccessDefault;
|
||||
showToast(success);
|
||||
try {
|
||||
form.reset();
|
||||
} catch {}
|
||||
} else {
|
||||
const errorMsg = message || (config && config.errorMessage) || m.submitErrorDefault;
|
||||
showToast(errorMsg);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const errorMsg = (config && config.errorMessage) || m.submitErrorDefault;
|
||||
showToast(errorMsg);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (toastTimeout !== null) {
|
||||
window.clearTimeout(toastTimeout);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
{toastMessage ? (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: "16px",
|
||||
bottom: "16px",
|
||||
zIndex: 50,
|
||||
maxWidth: "320px",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||||
color: "#e5e7eb",
|
||||
fontSize: "14px",
|
||||
lineHeight: "1.4",
|
||||
boxShadow: "0 10px 25px rgba(15, 23, 42, 0.6)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{toastMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
|
||||
import { buildStaticHtml } from "@/features/export/buildStaticHtml";
|
||||
import { getCachedPublicProject } from "@/features/projects/publicCache";
|
||||
import PublicProjectPageClient from "./PublicProjectPageClient";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface PageParams {
|
||||
// Next.js App Router 에서는 params 가 Promise 로 전달되므로
|
||||
// 서버 컴포넌트에서 사용하기 전에 await 로 한 번 풀어줘야 한다.
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
// /p/[slug] 공개 페이지
|
||||
// - slug 에 해당하는 Project 의 contentJson 을 불러와 buildStaticHtml 로 정적 HTML 을 생성한다.
|
||||
// - Export ZIP 에 포함되는 index.html 과 동일한 app-root DOM 구조를 사용해 렌더링한다.
|
||||
// - 인증 없이 접근 가능하며, 폼 제출은 /api/forms/submit 을 통해 정적 Export 와 동일하게 동작한다.
|
||||
export default async function PublicProjectPage({ params }: PageParams) {
|
||||
// Next 가 넘겨주는 params 는 Promise 이므로, 먼저 await 해서 slug 를 꺼낸다.
|
||||
const resolvedParams = await params;
|
||||
const slugRaw = resolvedParams.slug ?? "";
|
||||
const slug = slugRaw.toString().trim();
|
||||
|
||||
if (!slug) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// 1차로 메모리 기반 퍼블릭 캐시에서 스냅샷을 조회한다.
|
||||
const cached = getCachedPublicProject(slug);
|
||||
|
||||
let blocks: Block[];
|
||||
let title: string;
|
||||
|
||||
if (cached) {
|
||||
blocks = cached.contentJson ?? [];
|
||||
title = cached.title ?? "";
|
||||
} else {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
title: true,
|
||||
slug: true,
|
||||
contentJson: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const rawContent = project.contentJson;
|
||||
let contentBlocks: Block[] = [];
|
||||
if (Array.isArray(rawContent)) {
|
||||
contentBlocks = rawContent as unknown as Block[];
|
||||
}
|
||||
blocks = contentBlocks;
|
||||
title = project.title;
|
||||
}
|
||||
|
||||
// Export index.html 과 동일한 규칙으로 ProjectConfig 를 구성한다.
|
||||
const projectConfig: ProjectConfig = {
|
||||
title,
|
||||
slug,
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#020617",
|
||||
bodyBgColorHex: "#020617",
|
||||
} as ProjectConfig;
|
||||
|
||||
const fullHtml = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// buildStaticHtml 이 생성한 index.html 에서 <main> ... </main> 블록만 추출해 렌더링한다.
|
||||
// 이렇게 하면 Export index.html 과 동일한 main/app-root DOM 구조를 재사용할 수 있다.
|
||||
const mainMatch = fullHtml.match(/<main[\s\S]*?<\/main>/);
|
||||
const mainHtml = mainMatch ? mainMatch[0] : "";
|
||||
|
||||
return <PublicProjectPageClient html={mainHtml} />;
|
||||
}
|
||||
+14
-7
@@ -1,12 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
// 메인 페이지 진입 시 대시보드로 안내한다.
|
||||
router.push("/dashboard");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-3xl font-bold">Page Builder MVP</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
에디터와 블록 시스템을 이 위에서 TDD로 구현합니다.
|
||||
</p>
|
||||
</div>
|
||||
<main className="flex min-h-screen items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">대시보드로 이동 중입니다...</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../../styles/editor.css";
|
||||
|
||||
export default function PreviewLayout({ children }: { children: ReactNode }) {
|
||||
// 프리뷰 화면에서도 에디터 크롬과 동일한 전용 스타일(editor.css)을 사용한다.
|
||||
// 콘텐츠 블록 자체의 모양은 builder.css(pb-*) 기준으로 렌더된다.
|
||||
return children;
|
||||
}
|
||||
+47
-22
@@ -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);
|
||||
@@ -39,27 +43,35 @@ export default function PreviewPage() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
let slug = (projectConfig as any)?.slug?.trim?.();
|
||||
const configSlugRaw = (projectConfig as any)?.slug?.trim?.();
|
||||
const configSlug =
|
||||
typeof configSlugRaw === "string" && configSlugRaw.length > 0 ? configSlugRaw : "";
|
||||
|
||||
if (!slug) {
|
||||
let querySlug = "";
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const fromQuery = url.searchParams.get("slug")?.trim();
|
||||
|
||||
if (fromQuery && fromQuery !== slug) {
|
||||
slug = fromQuery;
|
||||
updateProjectConfig({ slug: fromQuery } as any);
|
||||
if (fromQuery) {
|
||||
querySlug = fromQuery;
|
||||
}
|
||||
} catch {
|
||||
// URL 파싱 오류는 무시하고, autosave 복원 없이 진행한다.
|
||||
}
|
||||
}
|
||||
|
||||
if (!slug) return;
|
||||
const effectiveSlug = querySlug || configSlug;
|
||||
if (!effectiveSlug) return;
|
||||
|
||||
const key = `pb:autosave:${slug}`;
|
||||
const key = `pb:autosave:${effectiveSlug}`;
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return;
|
||||
|
||||
if (!raw) {
|
||||
// autosave 스냅샷이 없을 때만 URL 쿼리 슬러그를 projectConfig.slug 에 동기화한다.
|
||||
if (querySlug && querySlug !== configSlug) {
|
||||
updateProjectConfig({ slug: querySlug } as any);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { blocks?: any[]; projectConfig?: any };
|
||||
@@ -68,6 +80,9 @@ export default function PreviewPage() {
|
||||
}
|
||||
if (parsed.projectConfig) {
|
||||
updateProjectConfig(parsed.projectConfig as any);
|
||||
} else if (querySlug && querySlug !== configSlug) {
|
||||
// 스냅샷에 projectConfig 가 없을 때만 쿼리 슬러그를 반영한다.
|
||||
updateProjectConfig({ slug: querySlug } as any);
|
||||
}
|
||||
|
||||
// 프리뷰 진입 시 autosave 프로젝트를 복원하는 경우에도 에디터 히스토리는 새 프로젝트 기준으로만 유지되도록 초기화한다.
|
||||
@@ -81,8 +96,11 @@ export default function PreviewPage() {
|
||||
const preset = projectConfig?.canvasPreset ?? "full";
|
||||
|
||||
const mainStyle: CSSProperties = {};
|
||||
if (projectConfig?.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
||||
mainStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||
{
|
||||
const raw = typeof projectConfig?.bodyBgColorHex === "string" ? projectConfig.bodyBgColorHex.trim() : "";
|
||||
if (raw) {
|
||||
mainStyle.backgroundColor = raw;
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportZip = async () => {
|
||||
@@ -125,6 +143,13 @@ export default function PreviewPage() {
|
||||
? projectConfig.canvasWidthPx
|
||||
: null;
|
||||
|
||||
const projectSlugRaw = (projectConfig as any)?.slug?.trim?.();
|
||||
const projectSlug =
|
||||
typeof projectSlugRaw === "string" && projectSlugRaw.length > 0 ? projectSlugRaw : "";
|
||||
const editorHref = projectSlug
|
||||
? `/editor?slug=${encodeURIComponent(projectSlug)}`
|
||||
: "/editor";
|
||||
|
||||
if (widthPx != null) {
|
||||
canvasStyle.maxWidth = `${widthPx}px`;
|
||||
} else if (preset === "mobile") {
|
||||
@@ -140,33 +165,33 @@ export default function PreviewPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50" style={mainStyle}>
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between">
|
||||
<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-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
|
||||
type="button"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-slate-100 hover:bg-slate-800"
|
||||
className="inline-flex items-center rounded border border-slate-300 bg-white px-3 py-1 text-slate-800 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
void handleExportZip();
|
||||
}}
|
||||
>
|
||||
페이지 파일로 내보내기 (ZIP)
|
||||
{m.exportZipButtonLabel}
|
||||
</button>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
|
||||
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="/editor"
|
||||
className="inline-flex items-center rounded border border-slate-700 bg-slate-900 px-3 py-1 text-xs text-slate-100 hover:bg-slate-800"
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
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";
|
||||
|
||||
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
|
||||
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
|
||||
@@ -23,9 +28,14 @@ export default function ProjectSubmissionsPage() {
|
||||
const params = useParams() as { slug?: string } | null;
|
||||
const slug = (params?.slug ?? "").toString();
|
||||
|
||||
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);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// 마운트 시 현재 slug 기준으로 폼 제출 내역을 불러온다.
|
||||
useEffect(() => {
|
||||
@@ -45,21 +55,19 @@ export default function ProjectSubmissionsPage() {
|
||||
|
||||
if (res.status === 401) {
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
|
||||
setErrorMessage(m.errorUnauthorized);
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.status === 404) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
|
||||
);
|
||||
setErrorMessage(m.errorGeneric);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setErrorMessage(m.errorGeneric);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -73,7 +81,7 @@ export default function ProjectSubmissionsPage() {
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setErrorMessage(m.errorGeneric);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -102,41 +110,127 @@ export default function ProjectSubmissionsPage() {
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setErrorMessage(m.errorLogout);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
} catch {
|
||||
setErrorMessage(m.errorLogout);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.toggle("dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
|
||||
<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-xl font-semibold">폼 제출 내역</h1>
|
||||
<p className="text-xs text-slate-400">프로젝트에 연결된 폼으로 수집된 제출 데이터를 확인할 수 있습니다.</p>
|
||||
<h1 className="text-2xl font-bold tracking-tight">{m.headerTitle}</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
|
||||
<p className="mt-1 text-[11px] font-mono text-slate-500 dark:text-slate-400">{slug}</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">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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>{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>{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>{t.navSubmissions}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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>{t.themeToggleLabel}</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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={() => {
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
void handleLogout();
|
||||
}}
|
||||
>
|
||||
{t.logoutLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-300">
|
||||
<span className="font-mono text-[11px]">{slug}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
|
||||
{status === "error" && errorMessage && (
|
||||
<p className="text-xs text-red-300 mb-3">{errorMessage}</p>
|
||||
<p className="text-xs text-red-500 mb-3 dark:text-red-300">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="text-xs text-slate-400 mb-3">폼 제출 내역을 불러오는 중입니다...</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-400">아직 수집된 폼 제출 내역이 없습니다.</p>
|
||||
<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-800 text-slate-400">
|
||||
<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">기타 필드</th>
|
||||
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="py-2 pr-4">{m.tableHeaderCreatedAt}</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>
|
||||
@@ -154,15 +248,18 @@ export default function ProjectSubmissionsPage() {
|
||||
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||
<td className="py-2 pr-4 text-slate-300 text-[11px]">
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-slate-200 hover:bg-slate-50 dark:border-slate-900 dark:hover:bg-slate-900/60"
|
||||
>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 text-[11px]">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{name}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{email}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{phone}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{birthdate}</td>
|
||||
<td className="py-2 pr-4 text-slate-300 whitespace-pre-wrap">
|
||||
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{name}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{email}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{phone}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{birthdate}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 whitespace-pre-wrap">
|
||||
{renderOtherFields(payload)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+147
-72
@@ -11,7 +11,13 @@ import {
|
||||
ListChecks,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LayoutDashboard,
|
||||
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;
|
||||
@@ -24,11 +30,15 @@ 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);
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const pageSize = 10;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,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;
|
||||
@@ -65,7 +73,7 @@ export default function ProjectsPage() {
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage("프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setErrorMessage(p.errorFetchGeneric);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -84,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;
|
||||
}
|
||||
@@ -98,7 +104,7 @@ export default function ProjectsPage() {
|
||||
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setErrorMessage(p.errorDeleteSingle);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,7 +120,7 @@ export default function ProjectsPage() {
|
||||
setErrorMessage(null);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setErrorMessage("프로젝트 삭제 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setErrorMessage(p.errorDeleteSingle);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,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;
|
||||
}
|
||||
|
||||
@@ -166,7 +170,7 @@ export default function ProjectsPage() {
|
||||
|
||||
if (okSlugs.length === 0) {
|
||||
setStatus("error");
|
||||
setErrorMessage("선택한 프로젝트를 삭제하는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
setErrorMessage(p.errorDeleteBulkAllFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,85 +186,151 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleTheme = () => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.toggle("dark");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex flex-col bg-slate-950 text-slate-50">
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between bg-slate-950/80 backdrop-blur">
|
||||
<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-xl font-semibold">프로젝트 목록</h1>
|
||||
<p className="text-xs 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-2 text-xs">
|
||||
<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">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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>{t.navDashboard}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects"
|
||||
aria-current="page"
|
||||
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>{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>{t.navSubmissions}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 px-3 py-1 text-slate-200 hover:bg-slate-900"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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>{t.themeToggleLabel}</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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={() => {
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
void handleLogout();
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
{t.logoutLabel}
|
||||
</button>
|
||||
<Link
|
||||
href="/editor"
|
||||
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
|
||||
>
|
||||
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
|
||||
<span>새 프로젝트 만들기</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<section className="flex-1 px-6 py-4 overflow-auto">
|
||||
<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-300 mb-3">
|
||||
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
|
||||
<p className="text-xs text-red-500 mb-3 dark:text-red-300">
|
||||
{errorMessage ?? p.errorFetchGeneric}
|
||||
</p>
|
||||
)}
|
||||
{projects.length === 0 && status === "idle" && (
|
||||
<p className="text-xs text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">아직 저장된 프로젝트가 없습니다. 에디터에서 프로젝트를 저장해 보세요.</p>
|
||||
)}
|
||||
{projects.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2 text-[11px] text-slate-300">
|
||||
<div
|
||||
data-testid="projects-toolbar"
|
||||
className="flex items-center justify-between mb-2 text-[11px] text-slate-600 dark:text-slate-300"
|
||||
>
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
|
||||
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
|
||||
<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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-red-700 px-2 py-1 text-red-200 hover:bg-red-900/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-red-300 text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-40 disabled:cursor-not-allowed dark:border-red-700 dark:text-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60"
|
||||
disabled={selectedSlugs.length === 0}
|
||||
onClick={() => {
|
||||
void handleBulkDelete();
|
||||
}}
|
||||
>
|
||||
<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>{p.toolbarCreateNew}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-xs text-left border-collapse mt-2">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-800 text-slate-400">
|
||||
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="py-2 pr-2 w-8">
|
||||
<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))
|
||||
}
|
||||
@@ -275,24 +345,27 @@ 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>
|
||||
{pageProjects.map((project) => {
|
||||
const checked = selectedSlugs.includes(project.slug);
|
||||
return (
|
||||
<tr key={project.id} className="border-b border-slate-900 hover:bg-slate-900/60">
|
||||
<tr
|
||||
key={project.id}
|
||||
className="border-b border-slate-200 hover:bg-slate-50 dark:border-slate-900 dark:hover:bg-slate-900/60"
|
||||
>
|
||||
<td className="py-2 pr-2 w-8">
|
||||
<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) {
|
||||
@@ -305,10 +378,10 @@ export default function ProjectsPage() {
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-100">{project.title}</td>
|
||||
<td className="py-2 pr-4 text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{project.status}</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{project.title}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 font-mono text-[11px]">{project.slug}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{project.status}</td>
|
||||
<td className="py-2 pr-4 text-slate-500 dark:text-slate-400 text-[11px]">
|
||||
{new Date(project.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-500 text-[11px]">
|
||||
@@ -318,34 +391,34 @@ export default function ProjectsPage() {
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Link
|
||||
href={`/editor?slug=${encodeURIComponent(project.slug)}`}
|
||||
className="inline-flex items-center gap-1 text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline"
|
||||
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-300 hover:text-slate-100 underline-offset-2 hover:underline"
|
||||
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-300 hover:text-emerald-100 underline-offset-2 hover:underline"
|
||||
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"
|
||||
className="inline-flex items-center gap-1 text-red-300 hover:text-red-200 underline-offset-2 hover:underline"
|
||||
className="inline-flex items-center gap-1 text-red-600 hover:text-red-700 underline-offset-2 hover:underline dark:text-red-300 dark:hover:text-red-200"
|
||||
onClick={() => {
|
||||
void handleDelete(project.slug);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>삭제</span>
|
||||
<span>{p.actionDelete}</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -355,10 +428,12 @@ export default function ProjectsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-300">
|
||||
<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-900/70 border border-slate-700">
|
||||
전체 {totalCount}개 중{" "}
|
||||
<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">
|
||||
{p.pagerTotalPrefix}
|
||||
{totalCount}
|
||||
{p.pagerTotalCountSuffix}
|
||||
<span className="font-semibold">
|
||||
{totalCount === 0 ? 0 : startIndex + 1}–{Math.min(endIndex, totalCount)}
|
||||
</span>
|
||||
@@ -376,7 +451,7 @@ export default function ProjectsPage() {
|
||||
className={`w-6 h-6 rounded-full text-[10px] flex items-center justify-center border transition-colors ${
|
||||
isActive
|
||||
? "bg-sky-600 text-white border-sky-500 shadow-sm"
|
||||
: "bg-slate-900 text-slate-300 border-slate-700 hover:bg-slate-800"
|
||||
: "bg-white text-slate-700 border-slate-300 hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-300 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
@@ -388,19 +463,19 @@ export default function ProjectsPage() {
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === 1}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
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.max(1, p - 1))}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" aria-hidden="true" />
|
||||
<span>이전</span>
|
||||
<span>{p.pagerPrevLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage === totalPages}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded border border-slate-700 text-slate-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-800"
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
createdAt: string;
|
||||
projectSlug: string;
|
||||
projectTitle: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setStatus("loading");
|
||||
setErrorMessage(null);
|
||||
|
||||
const res = await fetch("/api/projects/submissions");
|
||||
|
||||
if (!res.ok) {
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.status === 401) {
|
||||
setStatus("error");
|
||||
setErrorMessage(m.errorUnauthorized);
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
setErrorMessage(m.errorGeneric);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as SubmissionItem[];
|
||||
|
||||
if (!cancelled) {
|
||||
setSubmissions(Array.isArray(data) ? data : []);
|
||||
setStatus("idle");
|
||||
setErrorMessage(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setErrorMessage(m.errorGeneric);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderOtherFields = (payload: Record<string, unknown>): string => {
|
||||
const knownKeys = new Set(["name", "email", "phone", "birthdate"]);
|
||||
const entries = Object.entries(payload).filter(([key]) => !knownKeys.has(key));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return entries
|
||||
.map(([key, value]) => {
|
||||
const stringValue = typeof value === "string" ? value : JSON.stringify(value);
|
||||
return `${key}: ${stringValue}`;
|
||||
})
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setErrorMessage(m.errorLogout);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
} catch {
|
||||
setErrorMessage(m.errorLogout);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">{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">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
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>{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>{t.navProjects}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/submissions"
|
||||
aria-current="page"
|
||||
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>{t.navSubmissions}</span>
|
||||
</Link>
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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={() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.toggle("dark");
|
||||
}}
|
||||
>
|
||||
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t.themeToggleLabel}</span>
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs 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={() => {
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-xs text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setIsMenuOpen(false);
|
||||
void handleLogout();
|
||||
}}
|
||||
>
|
||||
{t.logoutLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="flex-1 px-6 py-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
|
||||
{status === "error" && errorMessage && (
|
||||
<p className="text-xs text-red-500 mb-3 dark:text-red-300">{errorMessage}</p>
|
||||
)}
|
||||
|
||||
{status === "loading" && (
|
||||
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400">{m.loadingText}</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">{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>
|
||||
{submissions.map((item) => {
|
||||
const payload = (item.payload ?? {}) as Record<string, unknown>;
|
||||
|
||||
const nameValue = payload["name"];
|
||||
const emailValue = payload["email"];
|
||||
const phoneValue = payload["phone"];
|
||||
const birthdateValue = payload["birthdate"];
|
||||
|
||||
const name = typeof nameValue === "string" ? nameValue : "";
|
||||
const email = typeof emailValue === "string" ? emailValue : "";
|
||||
const phone = typeof phoneValue === "string" ? phoneValue : "";
|
||||
const birthdate = typeof birthdateValue === "string" ? birthdateValue : "";
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b border-slate-200 hover:bg-slate-50 dark:border-slate-900 dark:hover:bg-slate-900/60"
|
||||
>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 text-[11px]">
|
||||
{new Date(item.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{item.projectTitle ?? "-"}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{item.projectSlug}</td>
|
||||
<td className="py-2 pr-4 text-slate-900 dark:text-slate-100">{name}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{email}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{phone}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300">{birthdate}</td>
|
||||
<td className="py-2 pr-4 text-slate-600 dark:text-slate-300 whitespace-pre-wrap">
|
||||
{renderOtherFields(payload)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+112
-23
@@ -3,16 +3,43 @@
|
||||
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";
|
||||
|
||||
// 회원가입 페이지 컴포넌트
|
||||
// - 이메일/비밀번호를 입력받아 /api/auth/signup 으로 요청을 전송한다.
|
||||
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
|
||||
type PasswordStrengthLabel = "weak" | "medium" | "strong";
|
||||
|
||||
function getPasswordStrengthLabel(password: string): PasswordStrengthLabel | null {
|
||||
if (!password || password.length < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
if (/[a-z]/.test(password)) score += 1;
|
||||
if (/[A-Z]/.test(password)) score += 1;
|
||||
if (/[0-9]/.test(password)) score += 1;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) score += 1;
|
||||
if (password.length >= 12) score += 1;
|
||||
|
||||
if (password.length < 8 || score <= 2) {
|
||||
return "weak";
|
||||
}
|
||||
|
||||
if (score === 3) {
|
||||
return "medium";
|
||||
}
|
||||
|
||||
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("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -23,7 +50,7 @@ export default function SignupPage() {
|
||||
try {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!cancelled && res.ok) {
|
||||
router.push("/projects");
|
||||
router.push("/dashboard");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -41,6 +68,11 @@ export default function SignupPage() {
|
||||
e.preventDefault();
|
||||
|
||||
setError(null);
|
||||
if (password !== passwordConfirm) {
|
||||
setError(t.mismatchError);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -53,55 +85,111 @@ 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;
|
||||
}
|
||||
|
||||
router.push("/projects");
|
||||
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-950 text-slate-50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-slate-800 bg-slate-900/70 p-6 shadow-xl">
|
||||
<h1 className="text-lg font-semibold mb-1">회원가입</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.</p>
|
||||
<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">{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-200">
|
||||
이메일
|
||||
<label htmlFor="email" className="block text-slate-800 dark:text-slate-200">
|
||||
{t.emailLabel}
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="password" className="block text-slate-200">
|
||||
비밀번호
|
||||
<label htmlFor="password" className="block text-slate-800 dark:text-slate-200">
|
||||
{t.passwordLabel}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-50 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{strengthLabel && (
|
||||
<div className="mt-1 space-y-1">
|
||||
<div className="h-1.5 rounded-full bg-slate-200/70 dark:bg-slate-700/70 overflow-hidden">
|
||||
<div
|
||||
data-testid="password-strength-bar"
|
||||
className={
|
||||
"h-full transition-all " +
|
||||
(strengthLabel === "weak"
|
||||
? "w-1/3 bg-red-500"
|
||||
: strengthLabel === "medium"
|
||||
? "w-2/3 bg-amber-500"
|
||||
: "w-full bg-emerald-600")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
"text-[11px] " +
|
||||
(strengthLabel === "weak"
|
||||
? "text-red-500 dark:text-red-300"
|
||||
: strengthLabel === "medium"
|
||||
? "text-amber-500 dark:text-amber-300"
|
||||
: "text-emerald-600 dark:text-emerald-300")
|
||||
}
|
||||
>
|
||||
{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"
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 focus:outline-none focus:ring-1 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-50"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
@@ -112,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-400">
|
||||
이미 계정이 있다면
|
||||
<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 = {
|
||||
// 팔레트 식별자
|
||||
@@ -32,7 +34,7 @@ export type ColorPickerFieldProps = {
|
||||
// 텍스트/버튼 등에서 공통으로 사용하는 기본 색상 팔레트
|
||||
export const TEXT_COLOR_PALETTE: ColorPaletteItem[] = [
|
||||
// 기본/중립 계열
|
||||
{ id: "default", label: "기본", color: "#e5e7eb" },
|
||||
{ id: "default", label: "없음", color: "" },
|
||||
// 투명
|
||||
{ id: "transparent", label: "투명", color: "transparent" },
|
||||
{ id: "muted", label: "연한", color: "#9ca3af" },
|
||||
@@ -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>) => {
|
||||
@@ -101,26 +106,28 @@ export function ColorPickerField({
|
||||
if (palette.length > 0) {
|
||||
if (selectedPaletteId) {
|
||||
selectedPalette = palette.find((item) => item.id === selectedPaletteId) ?? null;
|
||||
} else if (value) {
|
||||
} else {
|
||||
// value 가 빈 문자열이어도 color 가 "" 인 팔레트 항목(예: "없음")을 선택된 상태로 인식할 수 있도록
|
||||
// 항상 color 매칭으로 selectedPalette 를 계산한다.
|
||||
selectedPalette = palette.find((item) => item.color === value) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<div className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
aria-label={ariaLabelColorInput}
|
||||
className="h-8 w-8 rounded border border-slate-700 bg-slate-900 p-0"
|
||||
className="h-8 w-8 rounded border border-slate-300 bg-white p-0 dark:border-slate-700 dark:bg-slate-900"
|
||||
value={colorInputValue}
|
||||
onChange={handleColorInputChange}
|
||||
/>
|
||||
<input
|
||||
className="w-28 flex-none rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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}
|
||||
/>
|
||||
@@ -128,30 +135,32 @@ export function ColorPickerField({
|
||||
<div className="relative text-[11px] flex-none">
|
||||
<button
|
||||
type="button"
|
||||
className="w-28 rounded border border-slate-800 bg-slate-950/60 flex items-center justify-between px-2 py-1 text-left"
|
||||
className="w-28 rounded border border-slate-300 bg-white flex items-center justify-between px-2 py-1 text-left text-slate-900 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
className="inline-block h-3 w-3 rounded-full border border-slate-300 dark:border-slate-700"
|
||||
style={{ backgroundColor: selectedPalette?.color ?? value ?? "#ffffff" }}
|
||||
/>
|
||||
<span className="truncate text-slate-200">
|
||||
{selectedPalette?.label ?? "색상 팔레트"}
|
||||
<span className="truncate text-slate-700 dark:text-slate-100">
|
||||
{selectedPalette
|
||||
? m.paletteLabels[selectedPalette.id] ?? selectedPalette.label
|
||||
: m.dropdownLabelDefault}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-slate-500 text-[10px]">▼</span>
|
||||
<span className="text-slate-400 dark:text-slate-500 text-[10px]">▼</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-800 bg-slate-950 max-h-40 overflow-auto z-10">
|
||||
<div className="absolute right-0 top-full mt-1 w-32 rounded border border-slate-300 bg-white max-h-40 overflow-auto z-10 dark:border-slate-800 dark:bg-slate-950">
|
||||
{palette.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-900 last:border-b-0 ${
|
||||
className={`w-full flex items-center justify-between px-2 py-1 text-left text-[11px] border-b border-slate-200 last:border-b-0 dark:border-slate-800 ${
|
||||
selectedPaletteId === item.id
|
||||
? "bg-sky-900/40 text-sky-100"
|
||||
: "bg-transparent text-slate-300 hover:bg-slate-900/60"
|
||||
? "bg-sky-100 text-sky-900 dark:bg-sky-900/40 dark:text-sky-100"
|
||||
: "bg-white text-slate-900 hover:bg-slate-100 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
@@ -161,10 +170,10 @@ export function ColorPickerField({
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
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>
|
||||
))}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function NumericPropertyControl({
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>
|
||||
{label}
|
||||
{unitLabel ? ` ${unitLabel}` : ""}
|
||||
@@ -68,7 +68,7 @@ export function NumericPropertyControl({
|
||||
<div className="flex items-center gap-2">
|
||||
{presets && presets.length > 0 ? (
|
||||
<select
|
||||
className="w-32 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] outline-none focus:border-sky-500"
|
||||
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={`${label} 프리셋`}
|
||||
value={currentPresetId}
|
||||
onChange={handlePresetChange}
|
||||
@@ -84,7 +84,7 @@ export function NumericPropertyControl({
|
||||
<PropertySliderField
|
||||
label=""
|
||||
ariaLabelSlider={`${label} 슬라이더`}
|
||||
ariaLabelInput={`${label} 커스텀${unitLabel ? ` ${unitLabel}` : ""}`}
|
||||
ariaLabelInput={`${label} 커스텀`}
|
||||
value={Number.isFinite(value) ? value : 0}
|
||||
min={min}
|
||||
max={max}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function PropertySliderField({
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -60,7 +60,7 @@ export function PropertySliderField({
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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={ariaLabelInput}
|
||||
value={Number.isFinite(value) ? value : ""}
|
||||
onChange={handleInputChange}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import { useState, useMemo, type CSSProperties } from "react";
|
||||
import type {
|
||||
Block,
|
||||
TextBlockProps,
|
||||
@@ -27,11 +27,11 @@ import {
|
||||
computeVideoPublicTokens,
|
||||
} from "@/features/editor/utils/videoHelpers";
|
||||
import { computeButtonPublicTokens, computeButtonPbTokens } from "@/features/editor/utils/buttonHelpers";
|
||||
import { computeTextPublicTokens } from "@/features/editor/utils/textHelpers";
|
||||
import { computeTextPublicTokens, computeTextPbTokens } from "@/features/editor/utils/textHelpers";
|
||||
import { computeDividerPublicTokens } from "@/features/editor/utils/dividerHelpers";
|
||||
import { computeImagePublicTokens } from "@/features/editor/utils/imageHelpers";
|
||||
import { computeListPublicTokens } from "@/features/editor/utils/listHelpers";
|
||||
import { computeSectionPublicTokens } from "@/features/editor/utils/sectionHelpers";
|
||||
import { computeSectionPublicTokens, computeSectionExportTokens } from "@/features/editor/utils/sectionHelpers";
|
||||
import {
|
||||
computeFormInputPublicTokens,
|
||||
computeFormSelectPublicTokens,
|
||||
@@ -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,24 +103,222 @@ const convertPxStringToEm = (value?: string | null) => {
|
||||
};
|
||||
|
||||
export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererProps) {
|
||||
const sectionBlocks = blocks.filter((b) => b.type === "section");
|
||||
const rootBlocks = blocks.filter((b) => !b.sectionId && b.type !== "section");
|
||||
|
||||
const locale = useAppLocale();
|
||||
const m = getPublicPageRendererMessages(locale);
|
||||
const [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [formMessage, setFormMessage] = useState<string>("");
|
||||
|
||||
const [activeFormId, setActiveFormId] = useState<string | null>(null);
|
||||
|
||||
// 폼 컨트롤러 기준으로 필수 필드 맵을 구성한다.
|
||||
const requiredFieldIdSet = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const block of blocks) {
|
||||
if (block.type !== "form") continue;
|
||||
const props = block.props as any;
|
||||
const fieldIds: string[] = Array.isArray(props.requiredFieldIds) ? props.requiredFieldIds : [];
|
||||
for (const id of fieldIds) {
|
||||
if (id) set.add(id);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}, [blocks]);
|
||||
|
||||
const submitFormByController = async (formBlock: Block) => {
|
||||
const props = formBlock.props as FormBlockProps;
|
||||
const tokens = computeFormControllerPublicTokens(formBlock, blocks);
|
||||
const fields = tokens.fields;
|
||||
|
||||
const missingRequiredLabels: string[] = [];
|
||||
|
||||
fields.forEach((field) => {
|
||||
if (!field.required) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = field.name;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
let isEmpty = false;
|
||||
|
||||
if (field.type === "checkbox") {
|
||||
const nodes = document.querySelectorAll<HTMLInputElement>(
|
||||
`input[type="checkbox"][name="${name}"]`,
|
||||
);
|
||||
const anyChecked = Array.from(nodes).some((el) => el.checked);
|
||||
isEmpty = !anyChecked;
|
||||
} else if (field.type === "radio") {
|
||||
const selected = document.querySelector<HTMLInputElement>(
|
||||
`input[type="radio"][name="${name}"]:checked`,
|
||||
);
|
||||
isEmpty = !selected;
|
||||
} else if (field.type === "select") {
|
||||
const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
|
||||
if (!selectEl) {
|
||||
isEmpty = true;
|
||||
} else {
|
||||
const value = selectEl.value;
|
||||
isEmpty = value == null || `${value}`.trim() === "";
|
||||
}
|
||||
} else {
|
||||
const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
|
||||
const textareaEl = document.querySelector<HTMLTextAreaElement>(`textarea[name="${name}"]`);
|
||||
|
||||
const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null);
|
||||
if (!valueSource) {
|
||||
isEmpty = true;
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const rawValue = (valueSource as any).value;
|
||||
const value = typeof rawValue === "string" ? rawValue : rawValue != null ? String(rawValue) : "";
|
||||
isEmpty = value.trim() === "";
|
||||
}
|
||||
}
|
||||
|
||||
if (isEmpty) {
|
||||
const label = (field.label || field.name || "").trim();
|
||||
if (label && !missingRequiredLabels.includes(label)) {
|
||||
missingRequiredLabels.push(label);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (missingRequiredLabels.length > 0) {
|
||||
setActiveFormId(formBlock.id);
|
||||
setFormStatus("error");
|
||||
|
||||
const bulletLines = missingRequiredLabels.map((label) => `- ${label}`).join("\n");
|
||||
const message = `${m.requiredFieldsPrefix}\n${bulletLines}`;
|
||||
|
||||
setFormMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new FormData();
|
||||
data.append("__config", JSON.stringify(props));
|
||||
if (projectSlug && projectSlug.trim() !== "") {
|
||||
data.append("__projectSlug", projectSlug.trim());
|
||||
}
|
||||
|
||||
fields.forEach((field) => {
|
||||
const name = field.name;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === "checkbox") {
|
||||
const nodes = document.querySelectorAll<HTMLInputElement>(
|
||||
`input[type="checkbox"][name="${name}"]`,
|
||||
);
|
||||
nodes.forEach((el) => {
|
||||
if (el.checked) {
|
||||
data.append(name, el.value);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === "radio") {
|
||||
const selected = document.querySelector<HTMLInputElement>(
|
||||
`input[type="radio"][name="${name}"]:checked`,
|
||||
);
|
||||
if (selected) {
|
||||
data.append(name, selected.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (field.type === "select") {
|
||||
const selectEl = document.querySelector<HTMLSelectElement>(`select[name="${name}"]`);
|
||||
if (selectEl) {
|
||||
data.append(name, selectEl.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const inputEl = document.querySelector<HTMLInputElement>(`input[name="${name}"]`);
|
||||
const textareaEl = document.querySelector<HTMLTextAreaElement>(`textarea[name="${name}"]`);
|
||||
|
||||
const valueSource = (inputEl as HTMLInputElement | null) ?? (textareaEl as HTMLTextAreaElement | null);
|
||||
if (valueSource) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const value = (valueSource as any).value ?? "";
|
||||
data.append(name, value);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
setActiveFormId(formBlock.id);
|
||||
setFormStatus("submitting");
|
||||
setFormMessage("");
|
||||
|
||||
const res = await fetch("/api/forms/submit", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setFormStatus("success");
|
||||
setFormMessage(props.successMessage ?? m.submitSuccessDefault);
|
||||
} else {
|
||||
setFormStatus("error");
|
||||
setFormMessage(props.errorMessage ?? m.submitErrorDefault);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[PublicPageRenderer] form submit error", error);
|
||||
setFormStatus("error");
|
||||
setFormMessage(props.errorMessage ?? m.submitErrorDefault);
|
||||
}
|
||||
};
|
||||
|
||||
const renderBlock = (block: Block) => {
|
||||
if (block.type === "text") {
|
||||
const props = block.props as TextBlockProps;
|
||||
const tokens = computeTextPublicTokens(props);
|
||||
const textStyle: CSSProperties = { ...tokens.styleOverrides };
|
||||
const publicTokens = computeTextPublicTokens(props);
|
||||
const text = typeof props.text === "string" ? props.text : "";
|
||||
|
||||
const pbTokens = computeTextPbTokens({
|
||||
text,
|
||||
align: props.align ?? "left",
|
||||
size: props.size ?? "base",
|
||||
fontSizeMode: props.fontSizeMode ?? "scale",
|
||||
fontSizeScale: props.fontSizeScale ?? undefined,
|
||||
fontSizeCustom: props.fontSizeCustom ?? undefined,
|
||||
lineHeightMode: props.lineHeightMode ?? "scale",
|
||||
lineHeightScale: props.lineHeightScale ?? undefined,
|
||||
lineHeightCustom: props.lineHeightCustom ?? undefined,
|
||||
fontWeightMode: props.fontWeightMode ?? "scale",
|
||||
fontWeightScale: props.fontWeightScale ?? undefined,
|
||||
fontWeightCustom: props.fontWeightCustom ?? undefined,
|
||||
colorMode: props.colorMode ?? undefined,
|
||||
colorPalette: props.colorPalette ?? undefined,
|
||||
colorCustom: props.colorCustom ?? undefined,
|
||||
backgroundColorCustom: (props as any).backgroundColorCustom ?? undefined,
|
||||
maxWidthMode: props.maxWidthMode ?? "scale",
|
||||
maxWidthScale: props.maxWidthScale ?? undefined,
|
||||
underline: props.underline ?? false,
|
||||
strike: props.strike ?? false,
|
||||
italic: props.italic ?? false,
|
||||
});
|
||||
|
||||
const className = [
|
||||
pbTokens.alignClass,
|
||||
pbTokens.sizeClass,
|
||||
pbTokens.leadingClass,
|
||||
pbTokens.weightClass,
|
||||
pbTokens.colorClass,
|
||||
pbTokens.maxWidthClass,
|
||||
...pbTokens.extraClasses,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const textStyle: CSSProperties = { ...publicTokens.styleOverrides };
|
||||
|
||||
return (
|
||||
<p
|
||||
key={block.id}
|
||||
className={`${tokens.alignClass} ${tokens.sizeClass} text-slate-50 leading-relaxed whitespace-pre-wrap`}
|
||||
style={textStyle}
|
||||
>
|
||||
<p key={block.id} className={className} style={textStyle}>
|
||||
{props.text}
|
||||
</p>
|
||||
);
|
||||
@@ -127,31 +327,114 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
if (block.type === "formInput") {
|
||||
const props = block.props as FormInputBlockProps;
|
||||
const tokens = computeFormInputPublicTokens(props);
|
||||
const isRequired = requiredFieldIdSet.has(block.id);
|
||||
|
||||
const baseInputClass =
|
||||
"rounded border px-3 py-2 text-xs outline-none focus:border-sky-500";
|
||||
const labelDisplay = (props as any).labelDisplay ?? "visible";
|
||||
const wrapperClassName = `${tokens.wrapperLayoutClass} text-xs`;
|
||||
|
||||
const rawPlaceholder =
|
||||
typeof props.placeholder === "string" ? props.placeholder.trim() : "";
|
||||
const rawLabel = typeof props.label === "string" ? props.label.trim() : "";
|
||||
|
||||
// 플로팅 라벨 모드에서는 placeholder 텍스트를 인풋 안에 직접 렌더링하지 않고,
|
||||
// 라벨이 인풋 안/위에서 이동하도록 사용한다. placeholder 가 라벨과 다를 경우에만
|
||||
// 인풋 하단의 보조 설명 텍스트로 노출한다.
|
||||
const helperText =
|
||||
labelDisplay === "floating" && rawPlaceholder !== "" && rawPlaceholder !== rawLabel
|
||||
? rawPlaceholder
|
||||
: null;
|
||||
|
||||
const inputId = props.formFieldName || block.id;
|
||||
|
||||
if (labelDisplay === "floating") {
|
||||
// builder.css 기반 플로팅 라벨 마크업: pb-form-field/pb-form-field--floating + pb-form-label + pb-input
|
||||
// Export 경로와 동일한 DOM 구조를 사용해 프리뷰/퍼블릭/Export 가 모두 같은 레이아웃을 갖도록 맞춘다.
|
||||
|
||||
// paddingY 값을 사용해 플로팅 라벨과 인풋 높이를 함께 제어하기 위해 CSS 변수에 전달한다.
|
||||
const paddingY =
|
||||
typeof (props as any).paddingY === "number" && (props as any).paddingY >= 0
|
||||
? (props as any).paddingY
|
||||
: null;
|
||||
|
||||
// 플로팅 필드 wrapper 에도 토큰 기반 wrapperStyle 을 전달해,
|
||||
// --pb-input-border-color 와 같은 CSS 변수가 pb-form-field--floating 에 적용되도록 한다.
|
||||
const floatingFieldStyle: CSSProperties = { ...tokens.wrapperStyle };
|
||||
if (paddingY !== null) {
|
||||
(floatingFieldStyle as any)["--pb-input-padding-y"] = `${paddingY / 16}rem`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
data-testid="preview-form-input-wrapper"
|
||||
className="pb-form-field pb-form-field--floating"
|
||||
style={floatingFieldStyle}
|
||||
>
|
||||
{props.inputType === "textarea" ? (
|
||||
<textarea
|
||||
id={inputId}
|
||||
data-testid="preview-form-input"
|
||||
className={`pb-textarea ${tokens.widthClass}`}
|
||||
style={tokens.inputStyle}
|
||||
name={props.formFieldName}
|
||||
required={isRequired}
|
||||
// 공백 placeholder 로 라벨이 인풋 안에서 겹치지 않도록 한다.
|
||||
placeholder=" "
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={inputId}
|
||||
data-testid="preview-form-input"
|
||||
className={`pb-input ${tokens.widthClass}`}
|
||||
style={tokens.inputStyle}
|
||||
type={props.inputType === "email" ? "email" : "text"}
|
||||
name={props.formFieldName}
|
||||
required={isRequired}
|
||||
placeholder=" "
|
||||
/>
|
||||
)}
|
||||
{helperText && (
|
||||
<p className="mt-1 text-[10px]">{helperText}</p>
|
||||
)}
|
||||
<label htmlFor={inputId} className="pb-form-label">
|
||||
{props.label}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const labelSpan =
|
||||
labelDisplay === "hidden" ? (
|
||||
<span className="sr-only">{props.label}</span>
|
||||
) : (
|
||||
<span>{props.label}</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={block.id}
|
||||
data-testid="preview-form-input-wrapper"
|
||||
className={`${tokens.wrapperLayoutClass} text-xs text-slate-200`}
|
||||
className={wrapperClassName}
|
||||
style={tokens.wrapperStyle}
|
||||
>
|
||||
<span>{props.label}</span>
|
||||
{labelSpan}
|
||||
{props.inputType === "textarea" ? (
|
||||
<textarea
|
||||
data-testid="preview-form-input"
|
||||
className={`${baseInputClass} ${tokens.widthClass}`}
|
||||
className={`pb-textarea ${tokens.widthClass}`}
|
||||
style={tokens.inputStyle}
|
||||
name={props.formFieldName}
|
||||
required={isRequired}
|
||||
placeholder={props.placeholder || props.label}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
data-testid="preview-form-input"
|
||||
className={`${baseInputClass} ${tokens.widthClass}`}
|
||||
className={`pb-input ${tokens.widthClass}`}
|
||||
style={tokens.inputStyle}
|
||||
type={props.inputType === "email" ? "email" : "text"}
|
||||
name={props.formFieldName}
|
||||
required={isRequired}
|
||||
placeholder={props.placeholder || props.label}
|
||||
/>
|
||||
)}
|
||||
@@ -163,17 +446,45 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
const props = block.props as FormSelectBlockProps;
|
||||
const tokens = computeFormSelectPublicTokens(props);
|
||||
|
||||
const isRequired = requiredFieldIdSet.has(block.id);
|
||||
|
||||
const labelDisplay = (props as any).labelDisplay ?? "visible";
|
||||
const labelLayout = (props as any).labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelLayout === "inline" && labelDisplay === "visible";
|
||||
|
||||
const labelSpan =
|
||||
labelDisplay === "hidden" ? (
|
||||
<span className="sr-only">{props.label}</span>
|
||||
) : (
|
||||
<span className="pb-form-label">{props.label}</span>
|
||||
);
|
||||
|
||||
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
|
||||
const gapEm = pxToEm(gapPx);
|
||||
|
||||
const wrapperClassName = [
|
||||
isInlineLayout ? "flex items-center" : "flex flex-col gap-1",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const wrapperStyle: CSSProperties = isInlineLayout
|
||||
? { columnGap: gapEm }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<label key={block.id} className="flex flex-col gap-1 text-xs text-slate-200">
|
||||
<span>{props.label}</span>
|
||||
<label key={block.id} className={wrapperClassName} style={wrapperStyle}>
|
||||
{labelSpan}
|
||||
<div
|
||||
data-testid="preview-form-select"
|
||||
className={`${tokens.widthClass}`}
|
||||
style={tokens.wrapperStyle}
|
||||
>
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
className="pb-select"
|
||||
style={tokens.selectStyle}
|
||||
name={props.formFieldName}
|
||||
required={isRequired}
|
||||
defaultValue={props.options[0]?.value ?? ""}
|
||||
>
|
||||
{props.options.map((opt) => (
|
||||
@@ -192,43 +503,79 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
const tokens = computeFormCheckboxPublicTokens(props);
|
||||
const groupLabelMode = props.groupLabelMode ?? "text";
|
||||
|
||||
const isRequired = requiredFieldIdSet.has(block.id);
|
||||
|
||||
const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
|
||||
const labelLayout = (props as any).labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
||||
const optionLayout = (props as any).optionLayout ?? "stacked";
|
||||
const optionsLayoutClass = [
|
||||
"pb-form-options",
|
||||
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
||||
].join(" ");
|
||||
|
||||
const groupLabelSpan =
|
||||
groupLabelDisplay === "hidden" ? (
|
||||
<span className="sr-only" style={tokens.groupTextStyle}>
|
||||
{props.groupLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
||||
);
|
||||
|
||||
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
|
||||
const gapEm = pxToEm(gapPx);
|
||||
|
||||
const baseClass = [tokens.widthClass].filter(Boolean).join(" ");
|
||||
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
|
||||
|
||||
const groupStyle: CSSProperties = {
|
||||
...tokens.groupStyle,
|
||||
...(isInlineLayout ? { columnGap: gapEm } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
data-testid="preview-form-checkbox-group"
|
||||
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={tokens.groupStyle}
|
||||
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
|
||||
style={groupStyle}
|
||||
>
|
||||
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
|
||||
// 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}
|
||||
/>
|
||||
) : (
|
||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
||||
groupLabelSpan
|
||||
)}
|
||||
<div className="flex flex-col" data-testid="preview-form-checkbox-options" style={tokens.optionsStyle}>
|
||||
{props.options.map((opt) => (
|
||||
<div
|
||||
className={optionsLayoutClass}
|
||||
data-testid="preview-form-checkbox-options"
|
||||
style={tokens.optionsStyle}
|
||||
>
|
||||
{props.options.map((opt, index) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
data-testid="preview-form-checkbox-option-container"
|
||||
className="inline-flex items-center gap-1"
|
||||
className="pb-form-option"
|
||||
style={tokens.optionContainerStyle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||
className="h-4 w-4 rounded"
|
||||
name={props.formFieldName}
|
||||
value={opt.value}
|
||||
required={isRequired && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// 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}
|
||||
/>
|
||||
@@ -249,43 +596,78 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
const tokens = computeFormRadioPublicTokens(props);
|
||||
const groupLabelMode = props.groupLabelMode ?? "text";
|
||||
|
||||
const isRequired = requiredFieldIdSet.has(block.id);
|
||||
|
||||
const groupLabelDisplay = (props as any).groupLabelDisplay ?? "visible";
|
||||
const labelLayout = (props as any).labelLayout ?? "stacked";
|
||||
const isInlineLayout = labelLayout === "inline" && groupLabelDisplay === "visible";
|
||||
const optionLayout = (props as any).optionLayout ?? "stacked";
|
||||
const optionsLayoutClass = [
|
||||
"pb-form-options",
|
||||
optionLayout === "inline" ? "pb-form-options--inline" : "pb-form-options--stacked",
|
||||
].join(" ");
|
||||
|
||||
const groupLabelSpan =
|
||||
groupLabelDisplay === "hidden" ? (
|
||||
<span className="sr-only" style={tokens.groupTextStyle}>
|
||||
{props.groupLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
||||
);
|
||||
|
||||
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
|
||||
const gapEm = pxToEm(gapPx);
|
||||
|
||||
const baseClass = [tokens.widthClass].filter(Boolean).join(" ");
|
||||
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
|
||||
|
||||
const groupStyle: CSSProperties = {
|
||||
...tokens.groupStyle,
|
||||
...(isInlineLayout ? { columnGap: gapEm } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={block.id}
|
||||
data-testid="preview-form-radio-group"
|
||||
className={["flex flex-col gap-1", tokens.textSizeClass, "text-slate-200", tokens.widthClass]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
style={tokens.groupStyle}
|
||||
className={[layoutClass, baseClass].filter(Boolean).join(" ")}
|
||||
style={groupStyle}
|
||||
>
|
||||
{groupLabelMode === "image" && props.groupLabelImageUrl ? (
|
||||
// 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}
|
||||
/>
|
||||
) : (
|
||||
<span style={tokens.groupTextStyle}>{props.groupLabel}</span>
|
||||
groupLabelSpan
|
||||
)}
|
||||
<div className="flex flex-col" data-testid="preview-form-radio-options" style={tokens.optionsStyle}>
|
||||
{props.options.map((opt) => (
|
||||
<div
|
||||
className={optionsLayoutClass}
|
||||
data-testid="preview-form-radio-options"
|
||||
style={tokens.optionsStyle}
|
||||
>
|
||||
{props.options.map((opt, index) => (
|
||||
<label
|
||||
key={opt.value}
|
||||
className="inline-flex items-center gap-1"
|
||||
className="pb-form-option"
|
||||
style={tokens.optionContainerStyle}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
className="h-4 w-4"
|
||||
name={props.formFieldName}
|
||||
value={opt.value}
|
||||
required={isRequired && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// 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}
|
||||
/>
|
||||
@@ -339,7 +721,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
|
||||
let innerContent: React.ReactNode;
|
||||
if (!hasImage) {
|
||||
innerContent = <span className="whitespace-pre-wrap">{props.label}</span>;
|
||||
innerContent = props.label;
|
||||
} else {
|
||||
const src = (props as any).imageSrc as string;
|
||||
const altRaw = (props as any).imageAlt as string | undefined;
|
||||
@@ -372,11 +754,57 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={block.id} className={wrapperClass}>
|
||||
<a href={props.href} className={buttonClassName} style={buttonStyle}>
|
||||
const owningFormBlock = blocks.find(
|
||||
(b) => b.type === "form" && (b.props as FormBlockProps).submitButtonId === block.id,
|
||||
);
|
||||
const isSubmitButton = (() => {
|
||||
if (!owningFormBlock) return false;
|
||||
const tokens = computeFormControllerPublicTokens(owningFormBlock, blocks);
|
||||
return (tokens.fields ?? []).length > 0;
|
||||
})();
|
||||
|
||||
const handleClick = async (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!isSubmitButton || !owningFormBlock) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
await submitFormByController(owningFormBlock);
|
||||
};
|
||||
|
||||
const anchor = (
|
||||
<a
|
||||
href={props.href}
|
||||
className={buttonClassName}
|
||||
style={buttonStyle}
|
||||
onClick={isSubmitButton ? handleClick : undefined}
|
||||
>
|
||||
{innerContent}
|
||||
</a>
|
||||
);
|
||||
|
||||
if (!isSubmitButton) {
|
||||
return (
|
||||
<div key={block.id} className={wrapperClass}>
|
||||
{anchor}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={block.id} className={wrapperClass}>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{anchor}
|
||||
{owningFormBlock && activeFormId === owningFormBlock.id && formStatus !== "idle" && formMessage ? (
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
|
||||
}`}
|
||||
>
|
||||
{formMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -419,7 +847,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
: [
|
||||
{
|
||||
id: `${block.id}_item_1`,
|
||||
text: "리스트 아이템 1",
|
||||
text: "List item 1",
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
@@ -532,10 +960,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
)}
|
||||
</div>
|
||||
{props.captionText && props.captionText.trim() !== "" && (
|
||||
<p
|
||||
data-testid="preview-video-caption"
|
||||
className="mt-2 text-xs text-slate-400 hidden"
|
||||
>
|
||||
<p data-testid="preview-video-caption" className="pb-video-caption">
|
||||
{props.captionText}
|
||||
</p>
|
||||
)}
|
||||
@@ -545,184 +970,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
}
|
||||
|
||||
if (block.type === "form") {
|
||||
const props = block.props as FormBlockProps;
|
||||
const tokens = computeFormControllerPublicTokens(block, blocks);
|
||||
const fields = tokens.fields as any[];
|
||||
const hasFields = fields.length > 0;
|
||||
const submitLabelBase = tokens.submitLabel ?? (hasFields ? "폼 전송" : null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const form = e.currentTarget;
|
||||
const data = new FormData(form);
|
||||
|
||||
try {
|
||||
setFormStatus("submitting");
|
||||
setFormMessage("");
|
||||
|
||||
const res = await fetch("/api/forms/submit", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setFormStatus("success");
|
||||
setFormMessage(props.successMessage ?? "성공적으로 전송되었습니다.");
|
||||
form.reset();
|
||||
} else {
|
||||
setFormStatus("error");
|
||||
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[PublicPageRenderer] form submit error", error);
|
||||
setFormStatus("error");
|
||||
setFormMessage(props.errorMessage ?? "전송 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
key={block.id}
|
||||
data-testid="preview-form-controller"
|
||||
className={tokens.formClassName}
|
||||
style={tokens.formStyle}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* 폼 설정 전체를 서버로 함께 전달하기 위한 hidden 필드 */}
|
||||
<input type="hidden" name="__config" value={JSON.stringify(props)} />
|
||||
{projectSlug && projectSlug.trim() !== "" ? (
|
||||
<input type="hidden" name="__projectSlug" value={projectSlug.trim()} />
|
||||
) : null}
|
||||
{hasFields && (
|
||||
<div className="flex flex-col gap-2 text-xs text-slate-200">
|
||||
{fields.map((field: any) => (
|
||||
<label key={field.id} className="flex flex-col gap-1">
|
||||
<span>{field.label}</span>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
className="w-full min-h-[100px] rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
placeholder={field.label}
|
||||
required={field.required}
|
||||
/>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
required={field.required}
|
||||
defaultValue={field.options?.[0]?.value ?? ""}
|
||||
>
|
||||
{(field.options ?? []).map((opt: FormSelectOption) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === "checkbox" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* 그룹 타이틀: 텍스트/이미지 모드를 지원한다. alt 는 그룹 라벨 텍스트를 사용한다. */}
|
||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={field.groupLabelImageUrl}
|
||||
alt={field.label}
|
||||
className="max-h-10 w-auto max-w-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<span>{field.label}</span>
|
||||
)}
|
||||
{(field.options ?? []).map((opt: FormCheckboxOption, index: number) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
||||
<input
|
||||
className="h-4 w-4 rounded border-slate-700 bg-slate-900 text-sky-500"
|
||||
type="checkbox"
|
||||
name={field.name}
|
||||
value={opt.value}
|
||||
required={field.required && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={opt.labelImageUrl}
|
||||
alt={opt.label || field.label || "체크박스 옵션"}
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span>{opt.label}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : field.type === "radio" ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{field.groupLabelMode === "image" && field.groupLabelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={field.groupLabelImageUrl}
|
||||
alt={field.label}
|
||||
className="max-h-10 w-auto max-w-[200px] md:max-w-[300px] lg:max-w-[400px]"
|
||||
/>
|
||||
) : (
|
||||
<span>{field.label}</span>
|
||||
)}
|
||||
{(field.options ?? []).map((opt: FormRadioOption, index: number) => (
|
||||
<label key={opt.value} className="inline-flex items-center gap-1 text-xs">
|
||||
<input
|
||||
type="radio"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
name={field.name}
|
||||
value={opt.value}
|
||||
required={field.required && index === 0}
|
||||
/>
|
||||
{opt.labelImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={opt.labelImageUrl}
|
||||
alt={opt.label || field.label || "라디오 옵션"}
|
||||
className="h-5 w-auto max-w-[120px]"
|
||||
/>
|
||||
) : (
|
||||
<span>{opt.label}</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
className="w-full rounded border border-slate-700 bg-slate-900 px-3 py-2 text-xs outline-none focus:border-sky-500"
|
||||
name={field.name}
|
||||
type={field.type === "email" ? "email" : "text"}
|
||||
placeholder={field.label}
|
||||
required={field.required}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitLabelBase != null && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formStatus === "submitting"}
|
||||
className="inline-flex items-center justify-center rounded border border-emerald-500 bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{formStatus === "submitting" ? "전송 중..." : submitLabelBase}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{formStatus !== "idle" && formMessage ? (
|
||||
<p
|
||||
className={`text-xs mt-1 ${
|
||||
formStatus === "success" ? "text-emerald-400" : "text-rose-400"
|
||||
}`}
|
||||
>
|
||||
{formMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (block.type === "image") {
|
||||
@@ -748,18 +996,19 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
const renderSection = (section: Block) => {
|
||||
const props = section.props as SectionBlockProps;
|
||||
|
||||
const { backgroundClass, paddingYClass, maxWidthClass, gapXClass, alignItemsClass } =
|
||||
getSectionLayoutConfig(props);
|
||||
|
||||
const columns = props.columns && props.columns.length > 0 ? props.columns : [{ id: `${section.id}_col`, span: 12 }];
|
||||
const tokens = computeSectionPublicTokens(props);
|
||||
const exportTokens = computeSectionExportTokens(props);
|
||||
|
||||
const alignItems =
|
||||
props.alignItems === "center" ? "center" : props.alignItems === "bottom" ? "flex-end" : "flex-start";
|
||||
|
||||
return (
|
||||
<section
|
||||
key={section.id}
|
||||
data-testid="preview-section"
|
||||
data-section-id={section.id}
|
||||
className={`${backgroundClass} ${typeof props.paddingYPx === "number" ? "" : paddingYClass}`}
|
||||
className={exportTokens.sectionClasses}
|
||||
style={tokens.sectionStyle}
|
||||
>
|
||||
{tokens.hasBackgroundVideo && tokens.backgroundVideoSrc && (
|
||||
@@ -775,20 +1024,27 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
)}
|
||||
<div
|
||||
data-testid="preview-section-inner"
|
||||
className={`mx-auto ${typeof props.maxWidthPx === "number" ? "" : maxWidthClass} px-4`}
|
||||
className="pb-section-inner"
|
||||
style={tokens.innerWrapperStyle}
|
||||
>
|
||||
<div
|
||||
data-testid="preview-section-columns"
|
||||
className={`flex ${typeof props.gapXPx === "number" ? "" : gapXClass} ${alignItemsClass}`}
|
||||
style={tokens.columnsContainerStyle}
|
||||
className="pb-section-columns"
|
||||
style={{
|
||||
...tokens.columnsContainerStyle,
|
||||
alignItems,
|
||||
}}
|
||||
>
|
||||
{columns.map((col) => {
|
||||
const basis = `${(col.span / 12) * 100}%`;
|
||||
const columnBlocks = blocks.filter((b) => b.sectionId === section.id && b.columnId === col.id);
|
||||
|
||||
return (
|
||||
<div key={col.id} className="flex flex-col gap-4" style={{ flexBasis: basis }}>
|
||||
<div
|
||||
key={col.id}
|
||||
className="pb-section-column"
|
||||
style={{ flexBasis: basis, display: "flex", flexDirection: "column", rowGap: "1rem" }}
|
||||
>
|
||||
{columnBlocks.map((b) => (
|
||||
<div key={b.id}>{renderBlock(b)}</div>
|
||||
))}
|
||||
@@ -801,21 +1057,49 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
);
|
||||
};
|
||||
|
||||
type LayoutChunk =
|
||||
| { kind: "root"; blocks: Block[] }
|
||||
| { kind: "section"; section: Block };
|
||||
|
||||
const layoutChunks: LayoutChunk[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === "section") {
|
||||
layoutChunks.push({ kind: "section", section: block });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!block.sectionId) {
|
||||
const last = layoutChunks[layoutChunks.length - 1];
|
||||
if (!last || last.kind !== "root") {
|
||||
layoutChunks.push({ kind: "root", blocks: [block] });
|
||||
} else {
|
||||
last.blocks.push(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col text-slate-50">
|
||||
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 페이지 상단에 노출 */}
|
||||
{rootBlocks.length > 0 && (
|
||||
<section className="py-12">
|
||||
<div className="mx-auto max-w-3xl px-4 flex flex-col gap-4">
|
||||
{rootBlocks.map((b) => (
|
||||
<div className="flex-1 flex flex-col">
|
||||
{layoutChunks.map((chunk, index) => {
|
||||
if (chunk.kind === "root") {
|
||||
if (chunk.blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section key={`root-${index}`} className="pb-root">
|
||||
<div className="pb-root-inner">
|
||||
{chunk.blocks.map((b) => (
|
||||
<div key={b.id}>{renderBlock(b)}</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
);
|
||||
}
|
||||
|
||||
{/* 섹션 블록들 */}
|
||||
{sectionBlocks.map((section) => renderSection(section))}
|
||||
return renderSection(chunk.section);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ 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 { getEditorBlockDefaultsMessages } from "@/features/i18n/messages/editorBlockDefaults";
|
||||
import { DEFAULT_LOCALE, type AppLocale } from "@/features/i18n/locale";
|
||||
|
||||
// 블록 타입 정의: 텍스트/버튼/이미지/비디오/섹션/구분선/리스트/폼 블록/폼 요소 블록
|
||||
export type BlockType =
|
||||
@@ -559,6 +562,7 @@ export interface FormFieldStyleProps {
|
||||
optionGapPx?: number;
|
||||
labelGapPx?: number;
|
||||
labelLayout?: "stacked" | "inline";
|
||||
optionLayout?: "stacked" | "inline";
|
||||
borderRadius?: "none" | "sm" | "md" | "lg" | "full";
|
||||
// 필드 텍스트 색상/타이포그라피
|
||||
fontSizeCustom?: string;
|
||||
@@ -580,6 +584,7 @@ export interface FormInputBlockProps extends FormFieldStyleProps {
|
||||
inputType?: "text" | "email" | "textarea";
|
||||
placeholder?: string;
|
||||
labelMode?: FormLabelMode;
|
||||
labelDisplay?: "visible" | "hidden" | "floating";
|
||||
labelImageUrl?: string;
|
||||
labelImageAlt?: string;
|
||||
}
|
||||
@@ -596,6 +601,7 @@ export interface FormSelectBlockProps extends FormFieldStyleProps {
|
||||
options: FormSelectOption[];
|
||||
required?: boolean;
|
||||
labelMode?: FormLabelMode;
|
||||
labelDisplay?: "visible" | "hidden";
|
||||
labelImageUrl?: string;
|
||||
labelImageAlt?: string;
|
||||
}
|
||||
@@ -615,6 +621,7 @@ export interface FormCheckboxBlockProps extends FormFieldStyleProps {
|
||||
required?: boolean;
|
||||
// 그룹 타이틀 자체도 텍스트/이미지 모드를 가질 수 있도록 분리한다.
|
||||
groupLabelMode?: FormLabelMode;
|
||||
groupLabelDisplay?: "visible" | "hidden";
|
||||
groupLabelImageUrl?: string;
|
||||
groupLabelImageSource?: "url" | "upload";
|
||||
optionImageSource?: "url" | "upload";
|
||||
@@ -635,6 +642,7 @@ export interface FormRadioBlockProps extends FormFieldStyleProps {
|
||||
required?: boolean;
|
||||
// 라디오 그룹 타이틀의 텍스트/이미지 모드
|
||||
groupLabelMode?: FormLabelMode;
|
||||
groupLabelDisplay?: "visible" | "hidden";
|
||||
groupLabelImageUrl?: string;
|
||||
groupLabelImageSource?: "url" | "upload";
|
||||
optionImageSource?: "url" | "upload";
|
||||
@@ -671,6 +679,8 @@ export interface FormBlockProps {
|
||||
fields?: FormFieldConfig[];
|
||||
// v2 컨트롤러: 개별 폼 요소 블록과 제출 버튼을 연결하기 위한 ID 목록
|
||||
fieldIds?: string[];
|
||||
// v2 컨트롤러: fieldIds 로 연결된 필드 중 어떤 필드를 필수로 처리할지 관리하는 ID 목록
|
||||
requiredFieldIds?: string[];
|
||||
submitButtonId?: string | null;
|
||||
// 폼 레이아웃
|
||||
formWidthMode?: "auto" | "full" | "fixed";
|
||||
@@ -730,27 +740,27 @@ export interface EditorState {
|
||||
future: Block[][];
|
||||
projectConfig: ProjectConfig;
|
||||
updateProjectConfig: (partial: Partial<ProjectConfig>) => void;
|
||||
addTextBlock: () => void;
|
||||
addButtonBlock: () => void;
|
||||
addTextBlock: (locale?: AppLocale | null) => void;
|
||||
addButtonBlock: (locale?: AppLocale | null) => void;
|
||||
addImageBlock: () => void;
|
||||
addVideoBlock: () => void;
|
||||
addDividerBlock: () => void;
|
||||
addListBlock: () => void;
|
||||
addListBlock: (locale?: AppLocale | null) => void;
|
||||
addSectionBlock: () => void;
|
||||
addFormBlock: () => void;
|
||||
addFormInputBlock: () => void;
|
||||
addFormSelectBlock: () => void;
|
||||
addFormCheckboxBlock: () => void;
|
||||
addFormRadioBlock: () => void;
|
||||
addHeroTemplateSection: () => void;
|
||||
addFeaturesTemplateSection: () => void;
|
||||
addCtaTemplateSection: () => void;
|
||||
addFaqTemplateSection: () => void;
|
||||
addPricingTemplateSection: () => void;
|
||||
addTestimonialsTemplateSection: () => void;
|
||||
addBlogTemplateSection: () => void;
|
||||
addTeamTemplateSection: () => void;
|
||||
addFooterTemplateSection: () => void;
|
||||
addFormBlock: (locale?: AppLocale | null) => void;
|
||||
addFormInputBlock: (locale?: AppLocale | null) => void;
|
||||
addFormSelectBlock: (locale?: AppLocale | null) => void;
|
||||
addFormCheckboxBlock: (locale?: AppLocale | null) => void;
|
||||
addFormRadioBlock: (locale?: AppLocale | null) => void;
|
||||
addHeroTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addFeaturesTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addCtaTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addFaqTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addPricingTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addTestimonialsTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addBlogTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addTeamTemplateSection: (locale?: AppLocale | null) => void;
|
||||
addFooterTemplateSection: (locale?: AppLocale | null) => void;
|
||||
selectListItem: (itemId: string | null) => void;
|
||||
indentSelectedListItem: (blockId: string) => void;
|
||||
outdentSelectedListItem: (blockId: string) => void;
|
||||
@@ -810,6 +820,8 @@ const getNextFormFieldName = (blocks: Block[], prefix: string): string => {
|
||||
return `${prefix}-${max + 1}`;
|
||||
};
|
||||
|
||||
const isKoLocale = (locale?: AppLocale | null): boolean => (locale ?? DEFAULT_LOCALE) === "ko";
|
||||
|
||||
// 에디터 스토어 생성 함수 (테스트 및 앱에서 공유 사용)
|
||||
// set/get 은 zustand 내부 구현에 의해 주입되므로, 여기서는 any 로 완화해 사용한다.
|
||||
const createEditorState = (set: any, get: any): EditorState => {
|
||||
@@ -828,7 +840,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
history: [],
|
||||
future: [],
|
||||
projectConfig: {
|
||||
title: "새 페이지",
|
||||
title: "New page",
|
||||
slug: "my-landing",
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#020617",
|
||||
@@ -853,7 +865,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 텍스트 블록 추가: 기본 텍스트와 함께 생성 후 선택 상태로 만든다
|
||||
addTextBlock: () => {
|
||||
addTextBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
|
||||
const { selectedBlockId, blocks } = get();
|
||||
@@ -874,11 +886,12 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "text",
|
||||
props: {
|
||||
text: "새 텍스트",
|
||||
text: blockDefaults.text.defaultText,
|
||||
align: "left",
|
||||
size: "base",
|
||||
},
|
||||
@@ -894,7 +907,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// Hero 템플릿 섹션 추가: 섹션 1개와 기본 텍스트/버튼 블록들을 첫 컬럼에 배치한다
|
||||
addHeroTemplateSection: () => {
|
||||
addHeroTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -907,9 +920,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createHeroTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.hero,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -930,13 +945,8 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
});
|
||||
},
|
||||
|
||||
// 리스트 아이템 선택 상태를 업데이트한다.
|
||||
selectListItem: (itemId) => {
|
||||
set({ selectedListItemId: itemId ?? null });
|
||||
},
|
||||
|
||||
// Features 템플릿 섹션 추가: 3컬럼(4/4/4) 섹션과 각 컬럼의 제목/설명 텍스트를 생성한다
|
||||
addFeaturesTemplateSection: () => {
|
||||
addFeaturesTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -949,9 +959,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createFeaturesTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.features,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -973,7 +985,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// Blog 템플릿 섹션 추가: 3컬럼 포스트 카드(제목/요약)를 생성한다
|
||||
addBlogTemplateSection: () => {
|
||||
addBlogTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -986,9 +998,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createBlogTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.blog,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -1010,7 +1024,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// Team 템플릿 섹션 추가: 3컬럼 팀 카드(이름/역할/소개)를 생성한다
|
||||
addTeamTemplateSection: () => {
|
||||
addTeamTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -1023,9 +1037,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createTeamTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.team,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -1047,7 +1063,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// Footer 템플릿 섹션 추가: 링크/카피라이트 텍스트가 포함된 1컬럼 섹션을 생성한다
|
||||
addFooterTemplateSection: () => {
|
||||
addFooterTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -1060,9 +1076,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createFooterTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.footer,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -1084,7 +1102,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// CTA 템플릿 섹션 추가: 텍스트와 버튼이 포함된 1컬럼 섹션을 생성한다
|
||||
addCtaTemplateSection: () => {
|
||||
addCtaTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -1097,9 +1115,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createCtaTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.cta,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -1121,7 +1141,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// FAQ 템플릿 섹션 추가: 질문/답변 쌍이 수직으로 나열된 섹션을 생성한다
|
||||
addFaqTemplateSection: () => {
|
||||
addFaqTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -1134,9 +1154,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createFaqTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.faq,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -1158,7 +1180,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// Pricing 템플릿 섹션 추가: 3컬럼 요금제 카드(플랜 이름/가격/설명)를 생성한다
|
||||
addPricingTemplateSection: () => {
|
||||
addPricingTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -1171,9 +1193,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createPricingTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.pricing,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -1195,7 +1219,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// Testimonials 템플릿 섹션 추가: 3컬럼 후기 카드(본문/작성자)를 생성한다
|
||||
addTestimonialsTemplateSection: () => {
|
||||
addTestimonialsTemplateSection: (locale?: AppLocale | null) => {
|
||||
const { selectedBlockId, blocks } = get();
|
||||
let sectionId = createId();
|
||||
let replacingSectionId: string | null = null;
|
||||
@@ -1208,9 +1232,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const tpl = getEditorTemplatesMessages(locale);
|
||||
const { blocks: templateBlocks, lastSelectedId } = createTestimonialsTemplateBlocks({
|
||||
sectionId,
|
||||
createId,
|
||||
messages: tpl.testimonials,
|
||||
});
|
||||
|
||||
pushHistoryImpl(set, get);
|
||||
@@ -1258,7 +1284,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
type: "image",
|
||||
props: {
|
||||
src: "",
|
||||
alt: "이미지 설명",
|
||||
alt: "Image description",
|
||||
align: "center",
|
||||
widthMode: "auto",
|
||||
borderRadius: "md",
|
||||
@@ -1322,11 +1348,12 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 폼 입력 블록 추가: 루트에 기본 label/formFieldName 과 함께 생성 후 선택 상태로 만든다
|
||||
addFormInputBlock: () => {
|
||||
addFormInputBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const label = "입력 필드";
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
const label = blockDefaults.formInput.label;
|
||||
const formFieldName = getNextFormFieldName(blocks, "text");
|
||||
|
||||
const newBlock: Block = {
|
||||
@@ -1362,15 +1389,17 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 폼 셀렉트 블록 추가: 루트에 기본 label/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
||||
addFormSelectBlock: () => {
|
||||
addFormSelectBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const label = "선택 필드";
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
const selectDefaults = blockDefaults.formSelect;
|
||||
const label = selectDefaults.label;
|
||||
const formFieldName = getNextFormFieldName(blocks, "select");
|
||||
const options: FormSelectOption[] = [
|
||||
{ label: "옵션 1", value: "option_1" },
|
||||
{ label: "옵션 2", value: "option_2" },
|
||||
{ label: selectDefaults.option1Label, value: "option_1" },
|
||||
{ label: selectDefaults.option2Label, value: "option_2" },
|
||||
];
|
||||
|
||||
const newBlock: Block = {
|
||||
@@ -1404,15 +1433,17 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 폼 체크박스 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
||||
addFormCheckboxBlock: () => {
|
||||
addFormCheckboxBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const groupLabel = "체크박스";
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
const checkboxDefaults = blockDefaults.formCheckbox;
|
||||
const groupLabel = checkboxDefaults.groupLabel;
|
||||
const formFieldName = getNextFormFieldName(blocks, "checkbox");
|
||||
const options: FormCheckboxOption[] = [
|
||||
{ label: "체크 1", value: "check_1" },
|
||||
{ label: "체크 2", value: "check_2" },
|
||||
{ label: checkboxDefaults.option1Label, value: "check_1" },
|
||||
{ label: checkboxDefaults.option2Label, value: "check_2" },
|
||||
];
|
||||
|
||||
const newBlock: Block = {
|
||||
@@ -1447,15 +1478,17 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 폼 라디오 그룹 블록 추가: 루트에 기본 groupLabel/formFieldName/options 와 함께 생성 후 선택 상태로 만든다
|
||||
addFormRadioBlock: () => {
|
||||
addFormRadioBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
const { blocks } = get();
|
||||
|
||||
const groupLabel = "라디오 그룹";
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
const radioDefaults = blockDefaults.formRadio;
|
||||
const groupLabel = radioDefaults.groupLabel;
|
||||
const formFieldName = getNextFormFieldName(blocks, "radio");
|
||||
const options: FormRadioOption[] = [
|
||||
{ label: "옵션 A", value: "option_a" },
|
||||
{ label: "옵션 B", value: "option_b" },
|
||||
{ label: radioDefaults.optionALabel, value: "option_a" },
|
||||
{ label: radioDefaults.optionBLabel, value: "option_b" },
|
||||
];
|
||||
|
||||
const newBlock: Block = {
|
||||
@@ -1531,7 +1564,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 리스트 블록 추가: 기본 항목/정렬과 함께 생성 후 선택 상태로 만든다
|
||||
addListBlock: () => {
|
||||
addListBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
|
||||
const { selectedBlockId, blocks } = get();
|
||||
@@ -1552,15 +1585,18 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
const itemText = blockDefaults.list.firstItemText;
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "list",
|
||||
props: {
|
||||
items: ["리스트 아이템 1"],
|
||||
items: [itemText],
|
||||
itemsTree: [
|
||||
{
|
||||
id: `${id}_item_1`,
|
||||
text: "리스트 아이템 1",
|
||||
text: itemText,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
@@ -1606,28 +1642,31 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 폼 블록 추가: 기본 contact 폼 설정과 함께 생성 후 선택 상태로 만든다
|
||||
addFormBlock: () => {
|
||||
addFormBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
const formDefaults = blockDefaults.form;
|
||||
|
||||
const fields: FormFieldConfig[] = [
|
||||
{
|
||||
id: `${id}_field_name`,
|
||||
name: "name",
|
||||
label: "이름",
|
||||
label: formDefaults.nameLabel,
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: `${id}_field_email`,
|
||||
name: "email",
|
||||
label: "이메일",
|
||||
label: formDefaults.emailLabel,
|
||||
type: "email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: `${id}_field_message`,
|
||||
name: "message",
|
||||
label: "메시지",
|
||||
label: formDefaults.messageLabel,
|
||||
type: "textarea",
|
||||
required: true,
|
||||
},
|
||||
@@ -1639,8 +1678,8 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
successMessage: "성공적으로 전송되었습니다.",
|
||||
errorMessage: "전송 중 오류가 발생했습니다.",
|
||||
successMessage: formDefaults.successMessage,
|
||||
errorMessage: formDefaults.errorMessage,
|
||||
fields,
|
||||
fieldIds: [],
|
||||
submitButtonId: null,
|
||||
@@ -1657,7 +1696,7 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
},
|
||||
|
||||
// 버튼 블록 추가: 기본 라벨/링크와 함께 생성 후 선택 상태로 만든다
|
||||
addButtonBlock: () => {
|
||||
addButtonBlock: (locale?: AppLocale | null) => {
|
||||
const id = createId();
|
||||
|
||||
const { selectedBlockId, blocks } = get();
|
||||
@@ -1677,11 +1716,14 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blockDefaults = getEditorBlockDefaultsMessages(locale);
|
||||
|
||||
const newBlock: Block = {
|
||||
id,
|
||||
type: "button",
|
||||
props: {
|
||||
label: "버튼",
|
||||
label: blockDefaults.button.label,
|
||||
href: "#",
|
||||
align: "left",
|
||||
size: "md",
|
||||
@@ -1726,6 +1768,11 @@ const createEditorState = (set: any, get: any): EditorState => {
|
||||
}));
|
||||
},
|
||||
|
||||
// 현재 선택된 리스트 아이템 ID 를 상태에 저장한다.
|
||||
selectListItem: (itemId) => {
|
||||
set({ selectedListItemId: itemId });
|
||||
},
|
||||
|
||||
// 선택된 리스트 아이템을 들여쓰기한다.
|
||||
indentSelectedListItem: (blockId) => {
|
||||
const { blocks, selectedListItemId } = get();
|
||||
|
||||
@@ -18,6 +18,9 @@ export interface ButtonStyleInput {
|
||||
widthPx?: number | null | undefined;
|
||||
paddingX?: number | null | undefined;
|
||||
paddingY?: number | null | undefined;
|
||||
fontSizeCustom?: string | null | undefined;
|
||||
lineHeightCustom?: string | null | undefined;
|
||||
letterSpacingCustom?: string | null | undefined;
|
||||
fillColorCustom?: string | null | undefined;
|
||||
strokeColorCustom?: string | null | undefined;
|
||||
textColorCustom?: string | null | undefined;
|
||||
@@ -68,15 +71,13 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
|
||||
}
|
||||
|
||||
if (typeof input.paddingX === "number") {
|
||||
const paddingInline = pxToEm(input.paddingX);
|
||||
inlineStyles.push(`padding-left:${paddingInline}`);
|
||||
inlineStyles.push(`padding-right:${paddingInline}`);
|
||||
inlineStyles.push(`padding-left:${pxToEm(input.paddingX)}`);
|
||||
inlineStyles.push(`padding-right:${pxToEm(input.paddingX)}`);
|
||||
}
|
||||
|
||||
if (typeof input.paddingY === "number") {
|
||||
const paddingBlock = pxToEm(input.paddingY);
|
||||
inlineStyles.push(`padding-top:${paddingBlock}`);
|
||||
inlineStyles.push(`padding-bottom:${paddingBlock}`);
|
||||
inlineStyles.push(`padding-top:${pxToEm(input.paddingY)}`);
|
||||
inlineStyles.push(`padding-bottom:${pxToEm(input.paddingY)}`);
|
||||
}
|
||||
|
||||
if (input.fillColorCustom && input.fillColorCustom.trim() !== "") {
|
||||
@@ -91,6 +92,42 @@ export function computeButtonPbTokens(input: ButtonStyleInput): ButtonPbTokens {
|
||||
inlineStyles.push(`color:${input.textColorCustom.trim()}`);
|
||||
}
|
||||
|
||||
if (input.fontSizeCustom && input.fontSizeCustom.trim() !== "") {
|
||||
const fontSizeValue = input.fontSizeCustom.trim();
|
||||
if (fontSizeValue.endsWith("px")) {
|
||||
const fontSizeEm = convertPxStringToEm(fontSizeValue);
|
||||
if (fontSizeEm) {
|
||||
inlineStyles.push(`font-size:${fontSizeEm}`);
|
||||
}
|
||||
} else {
|
||||
inlineStyles.push(`font-size:${fontSizeValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.lineHeightCustom && input.lineHeightCustom.trim() !== "") {
|
||||
const lineHeightValue = input.lineHeightCustom.trim();
|
||||
if (lineHeightValue.endsWith("px")) {
|
||||
const lineHeightEm = convertPxStringToEm(lineHeightValue);
|
||||
if (lineHeightEm) {
|
||||
inlineStyles.push(`line-height:${lineHeightEm}`);
|
||||
}
|
||||
} else {
|
||||
inlineStyles.push(`line-height:${lineHeightValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.letterSpacingCustom && input.letterSpacingCustom.trim() !== "") {
|
||||
const letterSpacingValue = input.letterSpacingCustom.trim();
|
||||
if (letterSpacingValue.endsWith("px")) {
|
||||
const letterSpacingEm = convertPxStringToEm(letterSpacingValue);
|
||||
if (letterSpacingEm) {
|
||||
inlineStyles.push(`letter-spacing:${letterSpacingEm}`);
|
||||
}
|
||||
} else {
|
||||
inlineStyles.push(`letter-spacing:${letterSpacingValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
alignClass,
|
||||
sizeClass,
|
||||
@@ -155,11 +192,13 @@ export function computeButtonPublicTokens(props: ButtonBlockProps): ButtonPublic
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
styleOverrides.paddingInline = pxToEm(props.paddingX);
|
||||
const paddingEm = pxToEm(props.paddingX);
|
||||
styleOverrides.paddingInline = paddingEm;
|
||||
}
|
||||
|
||||
if (typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
styleOverrides.paddingBlock = pxToEm(props.paddingY);
|
||||
const paddingEm = pxToEm(props.paddingY);
|
||||
styleOverrides.paddingBlock = paddingEm;
|
||||
}
|
||||
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
|
||||
@@ -21,7 +21,33 @@ export const computeDividerExportTokens = (
|
||||
const marginY = typeof props.marginYPx === "number" && props.marginYPx > 0 ? props.marginYPx : 16;
|
||||
const marginEm = pxToEm(marginY);
|
||||
|
||||
const style = `border:0;border-bottom:${thickness} solid ${escapeAttr(color)};margin:${marginEm} 0;`;
|
||||
const widthMode = props.widthMode ?? "full";
|
||||
const align = props.align ?? "left";
|
||||
|
||||
const styleParts: string[] = [];
|
||||
styleParts.push("border:0");
|
||||
styleParts.push(`border-bottom:${thickness} solid ${escapeAttr(color)}`);
|
||||
styleParts.push(`margin:${marginEm} 0`);
|
||||
|
||||
if (widthMode === "auto") {
|
||||
styleParts.push("width:50%");
|
||||
} else if (widthMode === "fixed") {
|
||||
const widthPx =
|
||||
typeof props.widthPx === "number" && props.widthPx > 0 ? props.widthPx : 320;
|
||||
styleParts.push(`width:${pxToEm(widthPx)}`);
|
||||
}
|
||||
|
||||
if (widthMode === "auto" || widthMode === "fixed") {
|
||||
if (align === "center") {
|
||||
styleParts.push("margin-left:auto");
|
||||
styleParts.push("margin-right:auto");
|
||||
} else if (align === "right") {
|
||||
styleParts.push("margin-left:auto");
|
||||
styleParts.push("margin-right:0");
|
||||
}
|
||||
}
|
||||
|
||||
const style = `${styleParts.join(";")};`;
|
||||
|
||||
return { style };
|
||||
};
|
||||
|
||||
@@ -42,6 +42,12 @@ const normalizeTextColor = (raw: unknown): string => {
|
||||
return trimmed !== "" ? trimmed : "";
|
||||
};
|
||||
|
||||
// Export/Public 레이어용 모서리 둥글기 매핑
|
||||
// - none: 0px
|
||||
// - sm: 2px
|
||||
// - lg: 6px
|
||||
// - full: 9999px (완전 둥근 모서리)
|
||||
// - md(기본): 4px
|
||||
const computeRadiusPx = (radius: unknown): number => {
|
||||
const token = typeof radius === "string" ? radius : "md";
|
||||
if (token === "none") return 0;
|
||||
@@ -51,6 +57,22 @@ const computeRadiusPx = (radius: unknown): number => {
|
||||
return 4;
|
||||
};
|
||||
|
||||
// Editor 레이어용 모서리 둥글기 매핑
|
||||
// Export/Public 과 동일한 스케일을 사용하되, full 에 대해서만 pill 스타일(9999px)을 유지한다.
|
||||
// - none: 0px
|
||||
// - sm: 2px
|
||||
// - md: 4px (기본)
|
||||
// - lg: 6px
|
||||
// - full: 9999px
|
||||
const computeEditorRadiusPx = (radius: unknown): number => {
|
||||
const token = typeof radius === "string" ? radius : "md";
|
||||
if (token === "none") return 0;
|
||||
if (token === "sm") return 2;
|
||||
if (token === "lg") return 6;
|
||||
if (token === "full") return 9999;
|
||||
return 4;
|
||||
};
|
||||
|
||||
const computeWidthMode = (props: { widthMode?: string; fullWidth?: boolean }): "auto" | "full" | "fixed" => {
|
||||
if (typeof props.widthMode === "string") {
|
||||
return props.widthMode as "auto" | "full" | "fixed";
|
||||
@@ -101,9 +123,68 @@ export const computeFormInputExportTokens = (
|
||||
inputStyleParts.push(`width:${em}em`);
|
||||
}
|
||||
|
||||
const radiusPx = computeRadiusPx(props.borderRadius);
|
||||
const radiusToken = typeof props.borderRadius === "string" ? props.borderRadius : "md";
|
||||
let radiusPx = 4;
|
||||
if (radiusToken === "none") {
|
||||
radiusPx = 0;
|
||||
} else if (radiusToken === "sm") {
|
||||
radiusPx = 2;
|
||||
} else if (radiusToken === "lg") {
|
||||
radiusPx = 6;
|
||||
} else if (radiusToken === "full") {
|
||||
radiusPx = 9999;
|
||||
}
|
||||
inputStyleParts.push(`border-radius:${radiusPx}px`);
|
||||
|
||||
// 폰트 관련 커스텀 값: px 단위일 경우 em 으로 변환하고, 그렇지 않으면 원문 값을 그대로 사용한다.
|
||||
if (typeof props.fontSizeCustom === "string" && props.fontSizeCustom.trim() !== "") {
|
||||
const raw = props.fontSizeCustom.trim();
|
||||
if (raw.endsWith("px")) {
|
||||
const match = raw.match(/-?\d+(?:\.\d+)?/);
|
||||
if (match) {
|
||||
const px = parseFloat(match[0]);
|
||||
if (Number.isFinite(px)) {
|
||||
const em = px / 16;
|
||||
inputStyleParts.push(`font-size:${em}em`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inputStyleParts.push(`font-size:${escapeAttr(raw)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof props.lineHeightCustom === "string" && props.lineHeightCustom.trim() !== "") {
|
||||
const raw = props.lineHeightCustom.trim();
|
||||
if (raw.endsWith("px")) {
|
||||
const match = raw.match(/-?\d+(?:\.\d+)?/);
|
||||
if (match) {
|
||||
const px = parseFloat(match[0]);
|
||||
if (Number.isFinite(px)) {
|
||||
const em = px / 16;
|
||||
inputStyleParts.push(`line-height:${em}em`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inputStyleParts.push(`line-height:${escapeAttr(raw)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof props.letterSpacingCustom === "string" && props.letterSpacingCustom.trim() !== "") {
|
||||
const raw = props.letterSpacingCustom.trim();
|
||||
if (raw.endsWith("px")) {
|
||||
const match = raw.match(/-?\d+(?:\.\d+)?/);
|
||||
if (match) {
|
||||
const px = parseFloat(match[0]);
|
||||
if (Number.isFinite(px)) {
|
||||
const em = px / 16;
|
||||
inputStyleParts.push(`letter-spacing:${em}em`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inputStyleParts.push(`letter-spacing:${escapeAttr(raw)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyleAttr = inputStyleParts.length > 0 ? ` style="${inputStyleParts.join(";")}"` : "";
|
||||
|
||||
return {
|
||||
@@ -254,14 +335,9 @@ export const computeFormInputEditorTokens = (props: FormInputBlockProps): FormIn
|
||||
fieldStyle.width = `${props.widthPx}px`;
|
||||
}
|
||||
|
||||
const radius = props.borderRadius ?? "md";
|
||||
if (radius === "none") {
|
||||
fieldStyle.borderRadius = 0;
|
||||
} else if (radius === "sm") {
|
||||
fieldStyle.borderRadius = 4;
|
||||
} else if (radius === "lg" || radius === "full") {
|
||||
fieldStyle.borderRadius = 9999;
|
||||
}
|
||||
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
|
||||
const radiusPx = computeEditorRadiusPx(props.borderRadius);
|
||||
fieldStyle.borderRadius = radiusPx;
|
||||
|
||||
// 에디터에서는 px 기반 padding/타이포 값을 그대로 fieldStyle 에 반영해 미리보기에서 변화가 보이도록 한다.
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
@@ -336,14 +412,20 @@ export const computeFormSelectEditorTokens = (props: FormSelectBlockProps): Form
|
||||
fieldStyle.width = `${props.widthPx}px`;
|
||||
}
|
||||
|
||||
const radius = props.borderRadius ?? "md";
|
||||
if (radius === "none") {
|
||||
fieldStyle.borderRadius = 0;
|
||||
} else if (radius === "sm") {
|
||||
fieldStyle.borderRadius = 4;
|
||||
} else if (radius === "lg" || radius === "full") {
|
||||
fieldStyle.borderRadius = 9999;
|
||||
}
|
||||
// 모서리 둥글기: formSelect 는 Export/Public 레이어와 동일한 스케일(0/2/4/6/9999px)을 사용해
|
||||
// 에디터/프리뷰/퍼블릭 간 border-radius 가 일치하도록 한다.
|
||||
const selectRadiusToken = props.borderRadius ?? "md";
|
||||
const selectRadiusPx =
|
||||
selectRadiusToken === "none"
|
||||
? 0
|
||||
: selectRadiusToken === "sm"
|
||||
? 2
|
||||
: selectRadiusToken === "lg"
|
||||
? 6
|
||||
: selectRadiusToken === "full"
|
||||
? 9999
|
||||
: 4;
|
||||
fieldStyle.borderRadius = selectRadiusPx;
|
||||
|
||||
// 체크박스/라디오 그룹도 에디터에서 padding/타이포 스타일을 일부 반영해준다.
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
@@ -389,14 +471,9 @@ export const computeFormOptionGroupEditorTokens = (
|
||||
fieldStyle.width = `${props.widthPx}px`;
|
||||
}
|
||||
|
||||
const radius = props.borderRadius ?? "md";
|
||||
if (radius === "none") {
|
||||
fieldStyle.borderRadius = 0;
|
||||
} else if (radius === "sm") {
|
||||
fieldStyle.borderRadius = 4;
|
||||
} else if (radius === "lg" || radius === "full") {
|
||||
fieldStyle.borderRadius = 9999;
|
||||
}
|
||||
// 모서리 둥글기: Editor 레이어에서는 lg/full 토큰을 pill 형태(9999px)로 사용한다.
|
||||
const groupRadiusPx = computeEditorRadiusPx(props.borderRadius);
|
||||
fieldStyle.borderRadius = groupRadiusPx;
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
fieldStyle.paddingInline = `${props.paddingX}px`;
|
||||
@@ -535,23 +612,21 @@ export const computeFormInputPublicTokens = (props: FormInputBlockProps): FormIn
|
||||
}
|
||||
}
|
||||
|
||||
// 색상 관련 커스텀 값
|
||||
// 색상 관련 커스텀 값: builder.css 의 pb-input 기본 색상을 덮어쓰지 않도록,
|
||||
// 사용자가 커스텀 값을 설정했을 때에만 인라인 스타일을 적용한다.
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
inputStyle.color = props.textColorCustom.trim();
|
||||
} else {
|
||||
inputStyle.color = "#f9fafb";
|
||||
}
|
||||
|
||||
if (props.fillColorCustom && props.fillColorCustom.trim() !== "") {
|
||||
inputStyle.backgroundColor = props.fillColorCustom.trim();
|
||||
} else {
|
||||
inputStyle.backgroundColor = "#020617";
|
||||
}
|
||||
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
inputStyle.borderColor = props.strokeColorCustom.trim();
|
||||
} else {
|
||||
inputStyle.borderColor = "#334155";
|
||||
const borderColor = props.strokeColorCustom.trim();
|
||||
inputStyle.borderColor = borderColor;
|
||||
// 플로팅 라벨 패치가 필드 테두리 색을 참조할 수 있도록 CSS 변수에 전달한다.
|
||||
(wrapperStyle as any)["--pb-input-border-color"] = borderColor;
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
@@ -629,14 +704,12 @@ export const computeFormSelectPublicTokens = (props: FormSelectBlockProps): Form
|
||||
}
|
||||
}
|
||||
|
||||
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고, 없으면 기본 밝은 텍스트 색상을 사용한다.
|
||||
// 텍스트 색상: textColorCustom 이 설정되어 있으면 해당 색상을 사용하고,
|
||||
// 없으면 builder.css 의 pb-select 기본 색상을 그대로 사용한다.
|
||||
if (props.textColorCustom && props.textColorCustom.trim() !== "") {
|
||||
const colorValue = props.textColorCustom.trim();
|
||||
wrapperStyle.color = colorValue;
|
||||
selectStyle.color = colorValue;
|
||||
} else {
|
||||
wrapperStyle.color = "#f9fafb";
|
||||
selectStyle.color = "#f9fafb";
|
||||
}
|
||||
|
||||
// 배경/테두리 색상: fillColorCustom/strokeColorCustom 이 있으면 select 에 적용한다.
|
||||
@@ -694,6 +767,7 @@ const computeOptionGroupPublicTokensBase = (
|
||||
const textSizeClass = props.fontSizeCustom && props.fontSizeCustom.trim() !== "" ? "" : "text-xs";
|
||||
|
||||
if (widthMode === "fixed" && typeof props.widthPx === "number" && props.widthPx > 0) {
|
||||
// 퍼블릭 토큰에서는 고정 너비를 em 단위로 변환해 px 단위를 피한다.
|
||||
groupStyle.width = pxToEm(props.widthPx);
|
||||
}
|
||||
|
||||
@@ -743,9 +817,6 @@ const computeOptionGroupPublicTokensBase = (
|
||||
const colorValue = props.textColorCustom.trim();
|
||||
groupTextStyle.color = colorValue;
|
||||
optionTextStyle.color = colorValue;
|
||||
} else {
|
||||
groupTextStyle.color = "#e5e7eb";
|
||||
optionTextStyle.color = "#e5e7eb";
|
||||
}
|
||||
|
||||
if (typeof props.paddingX === "number" && props.paddingX >= 0) {
|
||||
@@ -783,7 +854,12 @@ const computeOptionGroupPublicTokensBase = (
|
||||
optionContainerStyle.borderRadius = `${radiusPx}px`;
|
||||
|
||||
if (typeof props.optionGapPx === "number" && props.optionGapPx >= 0) {
|
||||
optionsStyle.rowGap = pxToEm(props.optionGapPx);
|
||||
const gapPx = props.optionGapPx;
|
||||
const gapEm = pxToEm(gapPx);
|
||||
optionsStyle.rowGap = gapEm;
|
||||
if ((props as any).optionLayout === "inline") {
|
||||
optionsStyle.columnGap = gapEm;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -844,6 +920,10 @@ export const computeFormControllerPublicTokens = (
|
||||
.map((fieldBlock) => {
|
||||
const anyProps: any = fieldBlock.props ?? {};
|
||||
|
||||
const isRequired = Array.isArray(props.requiredFieldIds)
|
||||
? props.requiredFieldIds.includes(fieldBlock.id)
|
||||
: false;
|
||||
|
||||
if (fieldBlock.type === "formInput") {
|
||||
const name = anyProps.formFieldName ?? fieldBlock.id;
|
||||
const label = anyProps.label ?? anyProps.formFieldName ?? "입력 필드";
|
||||
@@ -854,7 +934,7 @@ export const computeFormControllerPublicTokens = (
|
||||
label,
|
||||
// 기존 PublicPageRenderer 컨트롤러 로직과 동일하게 formInput 은 항상 type "text" 로 취급한다.
|
||||
type: "text",
|
||||
required: Boolean(anyProps.required),
|
||||
required: isRequired,
|
||||
} satisfies FormControllerFieldPublicConfig;
|
||||
}
|
||||
|
||||
@@ -869,7 +949,7 @@ export const computeFormControllerPublicTokens = (
|
||||
label,
|
||||
type: "select",
|
||||
options,
|
||||
required: Boolean(anyProps.required),
|
||||
required: isRequired,
|
||||
} satisfies FormControllerFieldPublicConfig;
|
||||
}
|
||||
|
||||
@@ -886,7 +966,7 @@ export const computeFormControllerPublicTokens = (
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
required: isRequired,
|
||||
} satisfies FormControllerFieldPublicConfig;
|
||||
}
|
||||
|
||||
@@ -902,23 +982,14 @@ export const computeFormControllerPublicTokens = (
|
||||
options,
|
||||
groupLabelMode: anyProps.groupLabelMode,
|
||||
groupLabelImageUrl: anyProps.groupLabelImageUrl,
|
||||
required: Boolean(anyProps.required),
|
||||
required: isRequired,
|
||||
} satisfies FormControllerFieldPublicConfig;
|
||||
})
|
||||
: [];
|
||||
|
||||
const fields: FormControllerFieldPublicConfig[] =
|
||||
hasControllerFields && controllerFields.length > 0
|
||||
? controllerFields
|
||||
: Array.isArray(props.fields) && props.fields.length > 0
|
||||
? props.fields.map((field) => ({
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
label: field.label,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
}))
|
||||
: [];
|
||||
// 프리뷰/퍼블릭 렌더러에서는 FormBlock 의 v1 fields 설정을 사용하지 않고,
|
||||
// fieldIds 로 연결된 실제 폼 필드 블록들만 기반으로 폼 필드를 렌더링한다.
|
||||
const fields: FormControllerFieldPublicConfig[] = controllerFields;
|
||||
|
||||
const mappedSubmitButton = blocks.find(
|
||||
(b) => b.type === "button" && b.id === props.submitButtonId,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ListExportTokens {
|
||||
Tag: "ul" | "ol";
|
||||
items: string[];
|
||||
listStyleParts: string[];
|
||||
gapEm: number;
|
||||
}
|
||||
|
||||
export const computeListExportTokens = (props: ListBlockProps): ListExportTokens => {
|
||||
@@ -37,6 +38,26 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
|
||||
const align = alignToken === "center" ? "center" : alignToken === "right" ? "right" : "left";
|
||||
|
||||
const listStyleParts: string[] = [`text-align:${align}`];
|
||||
|
||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
||||
const bulletStyle = bulletStyleRaw === "none" ? "none" : bulletStyleRaw;
|
||||
listStyleParts.push(`list-style-type:${bulletStyle}`);
|
||||
|
||||
const gapPx =
|
||||
typeof props.gapYPx === "number"
|
||||
? props.gapYPx
|
||||
: props.gapY === "sm"
|
||||
? 4
|
||||
: props.gapY === "lg"
|
||||
? 16
|
||||
: 8;
|
||||
const gapEm = gapPx / 16;
|
||||
|
||||
// 리스트 아이템 간 간격은 CSS 커스텀 프로퍼티 --pb-list-gap 으로 전달한다.
|
||||
// 이렇게 하면 margin-bottom 계산은 builder.css 레이어에서만 수행되어,
|
||||
// 정적 Export HTML 의 style 속성에 직접적인 margin-bottom 이 포함되지 않는다.
|
||||
listStyleParts.push(`--pb-list-gap:${gapEm}em`);
|
||||
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
listStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
}
|
||||
@@ -45,6 +66,7 @@ export const computeListExportTokens = (props: ListBlockProps): ListExportTokens
|
||||
Tag,
|
||||
items,
|
||||
listStyleParts,
|
||||
gapEm,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -106,7 +128,11 @@ const convertPxStringToEm = (value?: string | null) => {
|
||||
|
||||
export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens => {
|
||||
const alignClass =
|
||||
props.align === "center" ? "text-center" : props.align === "right" ? "text-right" : "text-left";
|
||||
props.align === "center"
|
||||
? "pb-text-center"
|
||||
: props.align === "right"
|
||||
? "pb-text-right"
|
||||
: "pb-text-left";
|
||||
|
||||
const bulletStyleRaw = props.bulletStyle ?? (props.ordered ? "decimal" : "disc");
|
||||
|
||||
@@ -151,6 +177,10 @@ export const computeListPublicTokens = (props: ListBlockProps): ListPublicTokens
|
||||
listStyle.backgroundColor = props.backgroundColorCustom.trim();
|
||||
}
|
||||
|
||||
// 리스트 아이템 간 여백을 CSS 변수로 노출한다. 실제 margin-bottom 은 builder.css 의
|
||||
// .pb-list > li 규칙에서 --pb-list-gap 값을 참조해 계산한다.
|
||||
(listStyle as any)["--pb-list-gap"] = `${gapEm}em`;
|
||||
|
||||
const bulletStyle = bulletStyleRaw;
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { SectionBlockProps } from "@/features/editor/state/editorStore";
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export interface SectionExportTokens {
|
||||
sectionClasses: string;
|
||||
sectionStyleParts: string[];
|
||||
innerWrapperStyleParts: string[];
|
||||
columnsStyleParts: string[];
|
||||
backgroundVideoHtml: string;
|
||||
}
|
||||
|
||||
@@ -26,6 +30,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
|
||||
|
||||
const sectionClasses = ["pb-section", bgClass, pyClass].filter(Boolean).join(" ");
|
||||
const sectionStyleParts: string[] = [];
|
||||
const innerWrapperStyleParts: string[] = [];
|
||||
const columnsStyleParts: string[] = [];
|
||||
|
||||
if (typeof props.backgroundColorCustom === "string" && props.backgroundColorCustom.trim() !== "") {
|
||||
sectionStyleParts.push(`background-color:${props.backgroundColorCustom.trim()}`);
|
||||
@@ -65,6 +71,20 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
|
||||
sectionStyleParts.push(`background-repeat:${repeat}`);
|
||||
}
|
||||
|
||||
if (typeof props.paddingYPx === "number" && props.paddingYPx > 0) {
|
||||
const paddingEm = pxToEm(props.paddingYPx);
|
||||
sectionStyleParts.push(`padding-top:${paddingEm}`);
|
||||
sectionStyleParts.push(`padding-bottom:${paddingEm}`);
|
||||
}
|
||||
|
||||
if (typeof props.maxWidthPx === "number" && props.maxWidthPx > 0) {
|
||||
innerWrapperStyleParts.push(`max-width:${pxToEm(props.maxWidthPx)}`);
|
||||
}
|
||||
|
||||
if (typeof props.gapXPx === "number" && props.gapXPx > 0) {
|
||||
columnsStyleParts.push(`column-gap:${pxToEm(props.gapXPx)}`);
|
||||
}
|
||||
|
||||
const bgVideoSrc = props.backgroundVideoSrc;
|
||||
let backgroundVideoHtml = "";
|
||||
if (typeof bgVideoSrc === "string" && bgVideoSrc.trim() !== "") {
|
||||
@@ -77,6 +97,8 @@ export function computeSectionExportTokens(props: SectionBlockProps): SectionExp
|
||||
return {
|
||||
sectionClasses,
|
||||
sectionStyleParts,
|
||||
innerWrapperStyleParts,
|
||||
columnsStyleParts,
|
||||
backgroundVideoHtml,
|
||||
};
|
||||
}
|
||||
@@ -89,8 +111,6 @@ export interface SectionPublicTokens {
|
||||
backgroundVideoSrc: string | null;
|
||||
}
|
||||
|
||||
const pxToEm = (px: number, base = 16) => `${px / base}em`;
|
||||
|
||||
export function computeSectionPublicTokens(props: SectionBlockProps): SectionPublicTokens {
|
||||
const sectionStyle: CSSProperties = {};
|
||||
|
||||
|
||||
@@ -58,8 +58,10 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
|
||||
align === "center" ? "pb-text-center" : align === "right" ? "pb-text-right" : "pb-text-left";
|
||||
|
||||
const fontSizeMode = input.fontSizeMode ?? "scale";
|
||||
const fallbackScale: TextFontSizeScale = input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale: TextFontSizeScale = (input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale;
|
||||
const fallbackScale: TextFontSizeScale =
|
||||
input.size === "sm" ? "sm" : input.size === "lg" ? "lg" : "base";
|
||||
const fontSizeScale: TextFontSizeScale =
|
||||
(input.fontSizeScale as TextFontSizeScale | null) ?? fallbackScale;
|
||||
|
||||
const fontSizeMap: Record<TextFontSizeScale, string> = {
|
||||
xs: "pb-text-xs",
|
||||
@@ -71,8 +73,7 @@ export function computeTextPbTokens(input: TextStyleInput): TextPbTokens {
|
||||
"3xl": "pb-text-3xl",
|
||||
};
|
||||
|
||||
const sizeClass =
|
||||
fontSizeMode === "scale" && fontSizeScale && fontSizeMap[fontSizeScale]
|
||||
const sizeClass = fontSizeScale && fontSizeMap[fontSizeScale]
|
||||
? fontSizeMap[fontSizeScale]
|
||||
: "";
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { buildStaticHtml } from "@/app/api/export/route";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"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);
|
||||
|
||||
try {
|
||||
const maxAgeSeconds = 60 * 60 * 24 * 365; // 1 year
|
||||
document.cookie = `pb-locale=${nextLocale}; path=/; max-age=${maxAgeSeconds}`;
|
||||
} catch {
|
||||
// 쿠키 저장 실패는 무시 (로케일 상태는 여전히 컨텍스트에서 유지된다)
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
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;
|
||||
|
||||
imageBlockEmptyPreviewPlaceholder: string;
|
||||
columnAreaLabel: 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",
|
||||
|
||||
imageBlockEmptyPreviewPlaceholder:
|
||||
"Enter an image URL or upload a file to see the preview here.",
|
||||
columnAreaLabel: "Column area",
|
||||
},
|
||||
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: "아이템 내어쓰기",
|
||||
|
||||
imageBlockEmptyPreviewPlaceholder:
|
||||
"이미지 URL 을 입력하거나 파일을 업로드하면 여기에서 미리보기가 표시됩니다.",
|
||||
columnAreaLabel: "컬럼 영역",
|
||||
},
|
||||
};
|
||||
|
||||
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,121 @@
|
||||
import type { AppLocale } from "../locale";
|
||||
import { DEFAULT_LOCALE } from "../locale";
|
||||
|
||||
export type EditorBlockDefaultsMessages = {
|
||||
text: {
|
||||
defaultText: string;
|
||||
};
|
||||
list: {
|
||||
firstItemText: string;
|
||||
};
|
||||
button: {
|
||||
label: string;
|
||||
};
|
||||
form: {
|
||||
nameLabel: string;
|
||||
emailLabel: string;
|
||||
messageLabel: string;
|
||||
successMessage: string;
|
||||
errorMessage: string;
|
||||
};
|
||||
formInput: {
|
||||
label: string;
|
||||
};
|
||||
formSelect: {
|
||||
label: string;
|
||||
option1Label: string;
|
||||
option2Label: string;
|
||||
};
|
||||
formCheckbox: {
|
||||
groupLabel: string;
|
||||
option1Label: string;
|
||||
option2Label: string;
|
||||
};
|
||||
formRadio: {
|
||||
groupLabel: string;
|
||||
optionALabel: string;
|
||||
optionBLabel: string;
|
||||
};
|
||||
};
|
||||
|
||||
const EDITOR_BLOCK_DEFAULTS_MESSAGES: Record<AppLocale, EditorBlockDefaultsMessages> = {
|
||||
en: {
|
||||
text: {
|
||||
defaultText: "New text",
|
||||
},
|
||||
list: {
|
||||
firstItemText: "List item 1",
|
||||
},
|
||||
button: {
|
||||
label: "Button",
|
||||
},
|
||||
form: {
|
||||
nameLabel: "Name",
|
||||
emailLabel: "Email",
|
||||
messageLabel: "Message",
|
||||
successMessage: "Your message has been sent successfully.",
|
||||
errorMessage: "An error occurred while submitting.",
|
||||
},
|
||||
formInput: {
|
||||
label: "Input field",
|
||||
},
|
||||
formSelect: {
|
||||
label: "Select field",
|
||||
option1Label: "Option 1",
|
||||
option2Label: "Option 2",
|
||||
},
|
||||
formCheckbox: {
|
||||
groupLabel: "Checkbox group",
|
||||
option1Label: "Check 1",
|
||||
option2Label: "Check 2",
|
||||
},
|
||||
formRadio: {
|
||||
groupLabel: "Radio group",
|
||||
optionALabel: "Option A",
|
||||
optionBLabel: "Option B",
|
||||
},
|
||||
},
|
||||
ko: {
|
||||
text: {
|
||||
defaultText: "새 텍스트",
|
||||
},
|
||||
list: {
|
||||
firstItemText: "리스트 아이템 1",
|
||||
},
|
||||
button: {
|
||||
label: "버튼",
|
||||
},
|
||||
form: {
|
||||
nameLabel: "이름",
|
||||
emailLabel: "이메일",
|
||||
messageLabel: "메시지",
|
||||
successMessage: "성공적으로 전송되었습니다.",
|
||||
errorMessage: "전송 중 오류가 발생했습니다.",
|
||||
},
|
||||
formInput: {
|
||||
label: "입력 필드",
|
||||
},
|
||||
formSelect: {
|
||||
label: "셀렉트 필드",
|
||||
option1Label: "옵션 1",
|
||||
option2Label: "옵션 2",
|
||||
},
|
||||
formCheckbox: {
|
||||
groupLabel: "체크박스 그룹",
|
||||
option1Label: "옵션 1",
|
||||
option2Label: "옵션 2",
|
||||
},
|
||||
formRadio: {
|
||||
groupLabel: "라디오 그룹",
|
||||
optionALabel: "옵션 A",
|
||||
optionBLabel: "옵션 B",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getEditorBlockDefaultsMessages(
|
||||
locale: AppLocale | null | undefined,
|
||||
): EditorBlockDefaultsMessages {
|
||||
const key = (locale ?? DEFAULT_LOCALE) as AppLocale;
|
||||
return EDITOR_BLOCK_DEFAULTS_MESSAGES[key] ?? EDITOR_BLOCK_DEFAULTS_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",
|
||||
paddingYLabel: "Vertical padding",
|
||||
|
||||
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: "가로 패딩",
|
||||
paddingYLabel: "세로 패딩",
|
||||
|
||||
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",
|
||||
|
||||
fixedLengthLabel: "Fixed length",
|
||||
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: "고정 길이",
|
||||
|
||||
fixedLengthLabel: "고정 길이",
|
||||
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",
|
||||
|
||||
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",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "Checkbox line height",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
letterSpacingLabel: "Checkbox letter spacing",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
widthModeLabel: "Field width",
|
||||
widthModeOptionAuto: "Auto",
|
||||
widthModeOptionFull: "Full width",
|
||||
widthModeOptionFixed: "Fixed value",
|
||||
|
||||
fixedWidthLabel: "Field fixed width",
|
||||
fixedWidthUnitLabel: "(px)",
|
||||
|
||||
paddingXLabel: "Checkbox horizontal padding",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingYLabel: "Checkbox vertical padding",
|
||||
paddingYUnitLabel: "(px)",
|
||||
|
||||
optionGapLabel: "Checkbox option gap",
|
||||
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: "라벨/필드 간격",
|
||||
|
||||
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: "체크박스 텍스트 크기",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "체크박스 줄간격",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
letterSpacingLabel: "체크박스 자간",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
widthModeLabel: "필드 너비",
|
||||
widthModeOptionAuto: "자동",
|
||||
widthModeOptionFull: "전체 폭",
|
||||
widthModeOptionFixed: "고정 값",
|
||||
|
||||
fixedWidthLabel: "필드 고정 너비",
|
||||
fixedWidthUnitLabel: "(px)",
|
||||
|
||||
paddingXLabel: "체크박스 가로 패딩",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingYLabel: "체크박스 세로 패딩",
|
||||
paddingYUnitLabel: "(px)",
|
||||
|
||||
optionGapLabel: "체크박스 옵션 간격",
|
||||
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,223 @@
|
||||
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;
|
||||
|
||||
formFixedWidthPresetNarrow: string;
|
||||
formFixedWidthPresetNormal: string;
|
||||
formFixedWidthPresetWide: 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;
|
||||
|
||||
submitButtonNoneOptionLabel: 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",
|
||||
|
||||
formFixedWidthPresetNarrow: "Narrow",
|
||||
formFixedWidthPresetNormal: "Normal",
|
||||
formFixedWidthPresetWide: "Wide",
|
||||
|
||||
marginYLabel: "Form top/bottom margin",
|
||||
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",
|
||||
|
||||
submitButtonNoneOptionLabel: "(None)",
|
||||
|
||||
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: "폼 고정 너비",
|
||||
|
||||
formFixedWidthPresetNarrow: "좁게",
|
||||
formFixedWidthPresetNormal: "보통",
|
||||
formFixedWidthPresetWide: "넓게",
|
||||
|
||||
marginYLabel: "폼 위/아래 여백",
|
||||
marginYPresetNone: "없음",
|
||||
marginYPresetCompact: "좁게",
|
||||
marginYPresetNormal: "보통",
|
||||
marginYPresetSpacious: "넓게",
|
||||
|
||||
messagesSectionTitle: "폼 메시지",
|
||||
successMessageLabel: "성공 메시지",
|
||||
successMessagePlaceholder: "예: 성공적으로 전송되었습니다.",
|
||||
errorMessageLabel: "에러 메시지",
|
||||
errorMessagePlaceholder: "예: 전송 중 오류가 발생했습니다.",
|
||||
|
||||
controllerSectionTitle: "폼 컨트롤러",
|
||||
fieldMappingLegend: "폼 필드 매핑",
|
||||
fieldMappingAriaLabel: "폼 필드 매핑",
|
||||
requiredLabel: "필수",
|
||||
|
||||
submitButtonLabel: "Submit 버튼",
|
||||
submitButtonAriaLabel: "Submit 버튼",
|
||||
|
||||
submitButtonNoneOptionLabel: "선택 안 함",
|
||||
|
||||
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,359 @@
|
||||
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;
|
||||
|
||||
// Presets for label gap
|
||||
labelGapPresetTight: string;
|
||||
labelGapPresetNormal: string;
|
||||
labelGapPresetRelaxed: string;
|
||||
labelGapPresetExtra: 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;
|
||||
|
||||
// Presets for line height
|
||||
lineHeightPresetTight: string;
|
||||
lineHeightPresetNormal: string;
|
||||
lineHeightPresetLoose: string;
|
||||
|
||||
letterSpacingLabel: string;
|
||||
letterSpacingUnitLabel: string;
|
||||
|
||||
// Presets for letter spacing
|
||||
letterSpacingPresetTight: string;
|
||||
letterSpacingPresetNormal: string;
|
||||
letterSpacingPresetWide: string;
|
||||
|
||||
textAlignLabel: string;
|
||||
textAlignOptionLeft: string;
|
||||
textAlignOptionCenter: string;
|
||||
textAlignOptionRight: string;
|
||||
|
||||
widthModeLabel: string;
|
||||
widthModeAria: string;
|
||||
widthModeOptionAuto: string;
|
||||
widthModeOptionFull: string;
|
||||
widthModeOptionFixed: string;
|
||||
|
||||
fixedWidthLabel: string;
|
||||
fixedWidthUnitLabel: string;
|
||||
|
||||
// Presets for fixed width
|
||||
fixedWidthPresetSmall: string;
|
||||
fixedWidthPresetMedium: string;
|
||||
fixedWidthPresetLarge: string;
|
||||
|
||||
paddingXLabel: string;
|
||||
paddingXUnitLabel: string;
|
||||
|
||||
// Presets for horizontal padding
|
||||
paddingXPresetXs: string;
|
||||
paddingXPresetSm: string;
|
||||
paddingXPresetMd: string;
|
||||
paddingXPresetLg: string;
|
||||
paddingXPresetXl: string;
|
||||
|
||||
paddingYLabel: string;
|
||||
paddingYUnitLabel: string;
|
||||
|
||||
// Presets for vertical padding
|
||||
paddingYPresetXs: string;
|
||||
paddingYPresetSm: string;
|
||||
paddingYPresetMd: string;
|
||||
paddingYPresetLg: string;
|
||||
paddingYPresetXl: 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",
|
||||
labelGapUnitLabel: "(px)",
|
||||
|
||||
labelGapPresetTight: "Tight",
|
||||
labelGapPresetNormal: "Normal",
|
||||
labelGapPresetRelaxed: "Relaxed",
|
||||
labelGapPresetExtra: "Extra",
|
||||
|
||||
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",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "Field line height",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
lineHeightPresetTight: "Tight",
|
||||
lineHeightPresetNormal: "Normal",
|
||||
lineHeightPresetLoose: "Loose",
|
||||
|
||||
letterSpacingLabel: "Field letter spacing",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
letterSpacingPresetTight: "Tight",
|
||||
letterSpacingPresetNormal: "Normal",
|
||||
letterSpacingPresetWide: "Wide",
|
||||
|
||||
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)",
|
||||
|
||||
fixedWidthPresetSmall: "Small",
|
||||
fixedWidthPresetMedium: "Medium",
|
||||
fixedWidthPresetLarge: "Large",
|
||||
|
||||
paddingXLabel: "Field horizontal padding",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingXPresetXs: "Extra thin",
|
||||
paddingXPresetSm: "Thin",
|
||||
paddingXPresetMd: "Normal",
|
||||
paddingXPresetLg: "Wide",
|
||||
paddingXPresetXl: "Extra wide",
|
||||
|
||||
paddingYLabel: "Field vertical padding",
|
||||
paddingYUnitLabel: "(px)",
|
||||
|
||||
paddingYPresetXs: "Extra thin",
|
||||
paddingYPresetSm: "Thin",
|
||||
paddingYPresetMd: "Normal",
|
||||
paddingYPresetLg: "Wide",
|
||||
paddingYPresetXl: "Extra wide",
|
||||
|
||||
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: "라벨/필드 간격",
|
||||
labelGapUnitLabel: "(px)",
|
||||
|
||||
labelGapPresetTight: "타이트",
|
||||
labelGapPresetNormal: "보통",
|
||||
labelGapPresetRelaxed: "느슨",
|
||||
labelGapPresetExtra: "넓게",
|
||||
|
||||
labelImageUrlLabel: "라벨 이미지 URL",
|
||||
labelImageAltLabel: "라벨 이미지 대체 텍스트",
|
||||
|
||||
submitKeyLabel: "전송 키",
|
||||
|
||||
fieldTypeLabel: "필드 타입",
|
||||
fieldTypeAria: "필드 타입",
|
||||
fieldTypeOptionText: "텍스트",
|
||||
fieldTypeOptionEmail: "이메일",
|
||||
fieldTypeOptionTextarea: "긴 텍스트",
|
||||
|
||||
placeholderLabel: "Placeholder",
|
||||
placeholderAria: "Placeholder",
|
||||
|
||||
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
|
||||
|
||||
textSizeLabel: "필드 텍스트 크기",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "필드 줄간격",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
lineHeightPresetTight: "타이트",
|
||||
lineHeightPresetNormal: "보통",
|
||||
lineHeightPresetLoose: "루즈",
|
||||
|
||||
letterSpacingLabel: "필드 자간",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
letterSpacingPresetTight: "좁게",
|
||||
letterSpacingPresetNormal: "보통",
|
||||
letterSpacingPresetWide: "넓게",
|
||||
|
||||
textAlignLabel: "텍스트 정렬",
|
||||
textAlignOptionLeft: "왼쪽",
|
||||
textAlignOptionCenter: "가운데",
|
||||
textAlignOptionRight: "오른쪽",
|
||||
|
||||
widthModeLabel: "너비",
|
||||
widthModeAria: "너비",
|
||||
widthModeOptionAuto: "자동",
|
||||
widthModeOptionFull: "전체 폭",
|
||||
widthModeOptionFixed: "고정 값",
|
||||
|
||||
fixedWidthLabel: "필드 고정 너비",
|
||||
fixedWidthUnitLabel: "(px)",
|
||||
|
||||
fixedWidthPresetSmall: "작게",
|
||||
fixedWidthPresetMedium: "보통",
|
||||
fixedWidthPresetLarge: "넓게",
|
||||
|
||||
paddingXLabel: "필드 가로 패딩",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingXPresetXs: "아주 얇게",
|
||||
paddingXPresetSm: "얇게",
|
||||
paddingXPresetMd: "보통",
|
||||
paddingXPresetLg: "넓게",
|
||||
paddingXPresetXl: "아주 넓게",
|
||||
|
||||
paddingYLabel: "필드 세로 패딩",
|
||||
paddingYUnitLabel: "(px)",
|
||||
|
||||
paddingYPresetXs: "아주 얇게",
|
||||
paddingYPresetSm: "얇게",
|
||||
paddingYPresetMd: "보통",
|
||||
paddingYPresetLg: "넓게",
|
||||
paddingYPresetXl: "아주 넓게",
|
||||
|
||||
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,282 @@
|
||||
import type { AppLocale } from "../locale";
|
||||
import { DEFAULT_LOCALE } from "../locale";
|
||||
|
||||
export type EditorFormRadioPanelMessages = {
|
||||
sectionTitle: string;
|
||||
|
||||
// Group title & option configuration
|
||||
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;
|
||||
|
||||
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",
|
||||
|
||||
groupTitleTypeLabel: "Group title type",
|
||||
groupTitleTypeOptionText: "Text",
|
||||
groupTitleTypeOptionImage: "Image",
|
||||
|
||||
groupTitleDisplayLabel: "Group title display mode",
|
||||
groupTitleDisplayOptionVisible: "Visible (default)",
|
||||
groupTitleDisplayOptionHidden: "Hidden",
|
||||
|
||||
layoutLabel: "Group title layout",
|
||||
layoutOptionStacked: "Vertical (default)",
|
||||
layoutOptionInline: "Horizontal (inline)",
|
||||
|
||||
optionLayoutLabel: "Option layout",
|
||||
optionLayoutOptionStacked: "Vertical (default)",
|
||||
optionLayoutOptionInline: "Horizontal (inline)",
|
||||
|
||||
labelGapLabel: "Label/field gap",
|
||||
|
||||
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: "Radio text size",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "Radio line height",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
letterSpacingLabel: "Radio letter spacing",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
fixedWidthLabel: "Field fixed width",
|
||||
fixedWidthUnitLabel: "(px)",
|
||||
|
||||
paddingXLabel: "Radio horizontal padding",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingYLabel: "Radio vertical padding",
|
||||
paddingYUnitLabel: "(px)",
|
||||
|
||||
optionGapLabel: "Radio option gap",
|
||||
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: "라디오 필드",
|
||||
|
||||
groupTitleTypeLabel: "그룹 타이틀 타입",
|
||||
groupTitleTypeOptionText: "텍스트",
|
||||
groupTitleTypeOptionImage: "이미지",
|
||||
|
||||
groupTitleDisplayLabel: "그룹 타이틀 표시 방식",
|
||||
groupTitleDisplayOptionVisible: "표시 (기본)",
|
||||
groupTitleDisplayOptionHidden: "숨김",
|
||||
|
||||
layoutLabel: "그룹 타이틀 레이아웃",
|
||||
layoutOptionStacked: "세로 (기본)",
|
||||
layoutOptionInline: "가로 (인라인)",
|
||||
|
||||
optionLayoutLabel: "옵션 레이아웃",
|
||||
optionLayoutOptionStacked: "세로 (기본)",
|
||||
optionLayoutOptionInline: "가로 (인라인)",
|
||||
|
||||
labelGapLabel: "라벨/필드 간격",
|
||||
|
||||
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: "라디오 텍스트 크기",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "라디오 줄간격",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
letterSpacingLabel: "라디오 자간",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
fixedWidthLabel: "필드 고정 너비",
|
||||
fixedWidthUnitLabel: "(px)",
|
||||
|
||||
paddingXLabel: "라디오 가로 패딩",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingYLabel: "라디오 세로 패딩",
|
||||
paddingYUnitLabel: "(px)",
|
||||
|
||||
optionGapLabel: "라디오 옵션 간격",
|
||||
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",
|
||||
|
||||
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",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "Select line height",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
letterSpacingLabel: "Select letter spacing",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
widthModeLabel: "Field width",
|
||||
widthModeOptionAuto: "Auto",
|
||||
widthModeOptionFull: "Full width",
|
||||
widthModeOptionFixed: "Fixed value",
|
||||
|
||||
fixedWidthLabel: "Field fixed width",
|
||||
fixedWidthUnitLabel: "(px)",
|
||||
|
||||
paddingXLabel: "Select horizontal padding",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingYLabel: "Select vertical padding",
|
||||
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: "라벨/필드 간격",
|
||||
|
||||
fieldLabelLabel: "필드 라벨",
|
||||
submitKeyLabel: "전송 키",
|
||||
|
||||
optionsLabel: "옵션",
|
||||
addOptionButtonLabel: "옵션 추가",
|
||||
optionLabelPlaceholder: "라벨",
|
||||
optionValuePlaceholder: "값(value)",
|
||||
|
||||
requiredNoticeText: "필수 여부는 폼 컨트롤러에서 설정합니다.",
|
||||
|
||||
styleSectionTitle: "필드 스타일",
|
||||
|
||||
textSizeLabel: "셀렉트 텍스트 크기",
|
||||
textSizeUnitLabel: "(px)",
|
||||
|
||||
lineHeightLabel: "셀렉트 줄간격",
|
||||
lineHeightUnitLabel: "(px)",
|
||||
|
||||
letterSpacingLabel: "셀렉트 자간",
|
||||
letterSpacingUnitLabel: "(px)",
|
||||
|
||||
widthModeLabel: "필드 너비",
|
||||
widthModeOptionAuto: "자동",
|
||||
widthModeOptionFull: "전체 폭",
|
||||
widthModeOptionFixed: "고정 값",
|
||||
|
||||
fixedWidthLabel: "필드 고정 너비",
|
||||
fixedWidthUnitLabel: "(px)",
|
||||
|
||||
paddingXLabel: "셀렉트 가로 패딩",
|
||||
paddingXUnitLabel: "(px)",
|
||||
|
||||
paddingYLabel: "셀렉트 세로 패딩",
|
||||
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",
|
||||
|
||||
fixedWidthLabel: "Fixed width",
|
||||
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: "고정 너비",
|
||||
|
||||
fixedWidthLabel: "고정 너비",
|
||||
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",
|
||||
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",
|
||||
gapUnitLabel: "(px)",
|
||||
gapPresetTight: "Tight",
|
||||
gapPresetNormal: "Normal",
|
||||
gapPresetRelaxed: "Relaxed",
|
||||
},
|
||||
ko: {
|
||||
listItemsLabel: "리스트 아이템 (줄바꿈으로 구분)",
|
||||
listItemsAria: "리스트 아이템들",
|
||||
|
||||
alignLabel: "정렬",
|
||||
alignAria: "리스트 정렬",
|
||||
alignOptionLeft: "왼쪽",
|
||||
alignOptionCenter: "가운데",
|
||||
alignOptionRight: "오른쪽",
|
||||
|
||||
styleSectionTitle: "리스트 스타일",
|
||||
|
||||
fontSizeLabel: "글자 크기",
|
||||
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: "아이템 간 여백",
|
||||
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",
|
||||
maxWidthUnitLabel: "(px)",
|
||||
maxWidthPresetExtraNarrow: "Very narrow",
|
||||
maxWidthPresetNarrow: "Narrow",
|
||||
maxWidthPresetNormal: "Normal",
|
||||
maxWidthPresetWide: "Wide",
|
||||
maxWidthPresetExtraWide: "Very wide",
|
||||
|
||||
gapXLabel: "Column gap",
|
||||
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: "최대 폭",
|
||||
maxWidthUnitLabel: "(px)",
|
||||
maxWidthPresetExtraNarrow: "매우 좁게",
|
||||
maxWidthPresetNarrow: "좁게",
|
||||
maxWidthPresetNormal: "보통",
|
||||
maxWidthPresetWide: "넓게",
|
||||
maxWidthPresetExtraWide: "아주 넓게",
|
||||
|
||||
gapXLabel: "컬럼 간 간격",
|
||||
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];
|
||||
}
|
||||
Binary file not shown.
@@ -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",
|
||||
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: "글자 간격 커스텀",
|
||||
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",
|
||||
|
||||
fixedWidthLabel: "Fixed width",
|
||||
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: "고정 너비",
|
||||
|
||||
fixedWidthLabel: "고정 너비",
|
||||
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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Block } from "@/features/editor/state/editorStore";
|
||||
|
||||
interface PublicProjectSnapshot {
|
||||
slug: string;
|
||||
title: string;
|
||||
contentJson: Block[];
|
||||
}
|
||||
|
||||
function getStore(): Map<string, PublicProjectSnapshot> {
|
||||
const g = globalThis as any;
|
||||
if (!g.__PB_PUBLIC_PROJECTS) {
|
||||
g.__PB_PUBLIC_PROJECTS = new Map<string, PublicProjectSnapshot>();
|
||||
}
|
||||
return g.__PB_PUBLIC_PROJECTS as Map<string, PublicProjectSnapshot>;
|
||||
}
|
||||
|
||||
export function cachePublicProject(snapshot: PublicProjectSnapshot): void {
|
||||
const store = getStore();
|
||||
store.set(snapshot.slug, snapshot);
|
||||
}
|
||||
|
||||
export function getCachedPublicProject(slug: string): PublicProjectSnapshot | null {
|
||||
const store = getStore();
|
||||
return store.get(slug) ?? null;
|
||||
}
|
||||
+103
-9
@@ -28,6 +28,9 @@
|
||||
--pb-color-text-accent: #38bdf8; /* sky-400 */
|
||||
--pb-color-text-danger: #f97373; /* red-400 근처 */
|
||||
|
||||
/* Form input border 기본 색상 (플로팅 라벨 패치 등에서 재사용) */
|
||||
--pb-color-input-border: #4b5563; /* slate-600 근처 */
|
||||
|
||||
/* Text max width */
|
||||
--pb-text-maxw-prose: 60ch;
|
||||
--pb-text-maxw-narrow: 40ch;
|
||||
@@ -60,6 +63,7 @@ body {
|
||||
|
||||
.pb-section-inner {
|
||||
padding-inline: 1.5rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.pb-section-columns {
|
||||
@@ -149,7 +153,7 @@ body {
|
||||
|
||||
/* Text color palette */
|
||||
.pb-text-color-default {
|
||||
color: var(--pb-color-text-default);
|
||||
color: inherit;
|
||||
}
|
||||
.pb-text-color-muted {
|
||||
color: var(--pb-color-text-muted);
|
||||
@@ -201,6 +205,19 @@ body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Screen reader only utility (공통 sr-only 클래스) */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.pb-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #1f2937 #020617;
|
||||
@@ -260,7 +277,7 @@ body {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
border-width: 1px;
|
||||
text-decoration: none;
|
||||
@@ -419,11 +436,74 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
/* 플로팅 라벨 패치가 참조하는 입력 테두리 색 기본값 */
|
||||
--pb-input-border-color: var(--pb-color-input-border);
|
||||
}
|
||||
|
||||
.pb-form-field--floating {
|
||||
position: relative;
|
||||
margin-top: 0.5rem; /* 윗쪽 엘리먼트와는 살짝만 띄우고, 간격이 너무 넓지 않도록 조정한다. */
|
||||
/* 플로팅 라벨이 사용하는 공통 세로 패딩 기준값.
|
||||
이 값을 변경하면 인풋 높이와 라벨 위치가 함께 조정되도록 한다. */
|
||||
--pb-input-padding-y: 0.5rem;
|
||||
}
|
||||
|
||||
.pb-form-field--floating .pb-form-label {
|
||||
position: absolute;
|
||||
/* 인풋 내부 세로 패딩을 기준으로 라벨의 기본 위치를 계산한다. */
|
||||
top: calc(var(--pb-input-padding-y) + 0.3rem);
|
||||
left: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
pointer-events: none;
|
||||
transform-origin: left top;
|
||||
transition: top 0.3s ease, font-size 0.3s ease, color 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.pb-form-field--floating .pb-input,
|
||||
.pb-form-field--floating .pb-textarea {
|
||||
margin-top: 0.25rem;
|
||||
/* 기본 세로 패딩 + 라벨이 들어갈 여유 공간을 함께 적용한다. */
|
||||
padding-top: calc(var(--pb-input-padding-y) + 1rem);
|
||||
padding-bottom: var(--pb-input-padding-y);
|
||||
/* 세로 패딩을 0으로 줄이더라도 플로팅 라벨이 지나치게 눌리지 않도록 최소 높이를 보장한다. */
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
/* 포커스되었거나 값이 입력되어 placeholder 가 사라진 경우, 라벨을 인풋 바깥(상단 보더 근처)으로 플로팅한다. */
|
||||
.pb-form-field--floating .pb-input:focus ~ .pb-form-label,
|
||||
.pb-form-field--floating .pb-input:not(:placeholder-shown) ~ .pb-form-label,
|
||||
.pb-form-field--floating .pb-textarea:focus ~ .pb-form-label,
|
||||
.pb-form-field--floating .pb-textarea:not(:placeholder-shown) ~ .pb-form-label {
|
||||
top: -0.425rem; /* 인풋 상단 보더에 살짝 겹치는 정도로만 띄운다. */
|
||||
font-size: 0.725rem;
|
||||
padding: 0.25rem 1.725rem;
|
||||
color: #bbf7d0;
|
||||
/* 필드 테두리 색(또는 기본값)을 사용해 라벨 뒤에 부드러운 패치를 그린다.
|
||||
가운데는 var(--pb-input-border-color), 양 끝은 투명으로 페이드되도록 설정한다. */
|
||||
background: radial-gradient(
|
||||
ellipse closest-side,
|
||||
var(--pb-input-border-color) 70%,
|
||||
transparent
|
||||
);
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.pb-form-label {
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.pb-form-options {
|
||||
display: flex;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.pb-form-options--stacked {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pb-form-options--inline {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pb-form-option {
|
||||
@@ -431,22 +511,26 @@ body {
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.pb-input,
|
||||
.pb-select,
|
||||
.pb-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--pb-color-input-border);
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #1f2937;
|
||||
background-color: #020617;
|
||||
padding: 0.5rem 0.75rem;
|
||||
/* 세로 패딩은 CSS 변수로 정의해 플로팅 라벨/에디터 등이 함께 참조할 수 있게 한다. */
|
||||
padding: var(--pb-input-padding-y, 0.5rem) 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 기본 인풋/셀렉트도 일정 높이 이상을 유지해, 세로 패딩을 0 에 가깝게 줄이더라도 UI 가 붕괴되지 않도록 한다. */
|
||||
.pb-input,
|
||||
.pb-select {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.pb-textarea {
|
||||
min-height: 6rem;
|
||||
}
|
||||
@@ -457,6 +541,16 @@ body {
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: var(--pb-leading-normal);
|
||||
color: var(--pb-color-text-default);
|
||||
}
|
||||
|
||||
/* 리스트 아이템 간 여백은 --pb-list-gap 커스텀 프로퍼티로 제어한다.
|
||||
- Export/Preview 모두 동일한 gapEm 을 이 변수에 설정하고,
|
||||
- 실제 margin-bottom 계산은 CSS 레이어에서만 수행해 정적 HTML 인라인 스타일에
|
||||
margin-bottom 이 직접 등장하지 않도록 한다. */
|
||||
.pb-list > li {
|
||||
margin-bottom: var(--pb-list-gap, 0);
|
||||
}
|
||||
|
||||
.pb-list > li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* editor.css
|
||||
* - 에디터/프리뷰 상단 바, 좌우 패널, 캔버스 외곽 등 "에디터 크롬" 전용 스타일을 정의한다.
|
||||
* - 콘텐츠 블록(text/button/image/video/list/section/form 등)의 모양은 builder.css(pb-*) + 인라인 스타일로만 제어하고,
|
||||
* 이 파일에서는 블록 내부 스타일을 절대 정의하지 않는다.
|
||||
*/
|
||||
|
||||
/* 초기 상태에서는 별도 규칙 없이 파일만 생성해 두고,
|
||||
* 이후 에디터 전용 레이아웃/패널 스타일을 점진적으로 이동시킨다.
|
||||
*/
|
||||
|
||||
/*
|
||||
* 에디터/프리뷰용 스크롤바 테마
|
||||
* - .pb-scroll 클래스를 사용하는 컨테이너에서 라이트/다크 테마에 맞는 스크롤바 색상을 적용한다.
|
||||
* - builder.css 의 .pb-scroll 기본값은 다크 톤이므로, 여기서는 Tailwind dark 모드(html.dark)를 기준으로
|
||||
* 라이트/다크 테마별로 명시적으로 override 한다.
|
||||
*/
|
||||
|
||||
/* 라이트 모드: 밝은 배경에 어울리는 연한 스크롤바 */
|
||||
html:not(.dark) .pb-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 #e5e7eb; /* thumb / track */
|
||||
}
|
||||
|
||||
html:not(.dark) .pb-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
html:not(.dark) .pb-scroll::-webkit-scrollbar-track {
|
||||
background-color: #e5e7eb; /* slate-200 */
|
||||
}
|
||||
|
||||
html:not(.dark) .pb-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: #94a3b8; /* slate-400 */
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
html:not(.dark) .pb-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #64748b; /* slate-500 */
|
||||
}
|
||||
|
||||
/* 다크 모드: 기존 builder.css 톤과 유사한 다크 스크롤바 */
|
||||
html.dark .pb-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #1f2937 #020617; /* thumb / track */
|
||||
}
|
||||
|
||||
html.dark .pb-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
html.dark .pb-scroll::-webkit-scrollbar-track {
|
||||
background-color: #020617; /* slate-950 */
|
||||
}
|
||||
|
||||
html.dark .pb-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: #1f2937; /* slate-800 */
|
||||
border-radius: 9999px;
|
||||
border: 2px solid #020617;
|
||||
}
|
||||
|
||||
html.dark .pb-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #374151; /* slate-700 */
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@config "../../tailwind.config.js";
|
||||
@import "tailwindcss";
|
||||
|
||||
html,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: "class",
|
||||
content: [
|
||||
"./src/app/**/*.{ts,tsx}",
|
||||
"./src/components/**/*.{ts,tsx}",
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import "dotenv/config";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { signAccessToken } from "@/features/auth/authCrypto";
|
||||
|
||||
// /api/dashboard/overview TDD
|
||||
// - 로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다.
|
||||
// - 로그인하지 않은 경우 401 을 반환해야 한다.
|
||||
// - 프로젝트/제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다.
|
||||
|
||||
const BASE_URL = "http://localhost";
|
||||
|
||||
const inMemoryProjects: any[] = [];
|
||||
const inMemoryFormSubmissions: any[] = [];
|
||||
|
||||
vi.mock("@prisma/client", () => {
|
||||
class PrismaClientMock {
|
||||
project = {
|
||||
findMany: async (args: any = {}) => {
|
||||
const where = args.where ?? {};
|
||||
|
||||
let items = [...inMemoryProjects];
|
||||
|
||||
if (where.userId) {
|
||||
items = items.filter((p) => p.userId === where.userId);
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
|
||||
formSubmission = {
|
||||
findMany: async (args: any = {}) => {
|
||||
const where = args.where ?? {};
|
||||
|
||||
let items = [...inMemoryFormSubmissions];
|
||||
|
||||
if (where.userId) {
|
||||
items = items.filter((s) => s.userId === where.userId);
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { PrismaClient: PrismaClientMock };
|
||||
});
|
||||
|
||||
const TEST_USER = { id: "user-1", email: "dashboard@example.com", tokenVersion: 1 };
|
||||
const OTHER_USER = { id: "user-2", email: "dashboard2@example.com", tokenVersion: 1 };
|
||||
|
||||
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
|
||||
const token = await signAccessToken(user);
|
||||
return { cookie: `pb_access=${token}` };
|
||||
}
|
||||
|
||||
async function buildAuthHeaders() {
|
||||
return buildAuthHeadersFor(TEST_USER);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.AUTH_JWT_SECRET = "test-jwt-secret-dashboard";
|
||||
inMemoryProjects.length = 0;
|
||||
inMemoryFormSubmissions.length = 0;
|
||||
});
|
||||
|
||||
describe("/api/dashboard/overview", () => {
|
||||
it("로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다", async () => {
|
||||
const project1 = {
|
||||
id: "proj-1",
|
||||
userId: TEST_USER.id,
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
};
|
||||
|
||||
const project2 = {
|
||||
id: "proj-2",
|
||||
userId: TEST_USER.id,
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
};
|
||||
|
||||
const projectOther = {
|
||||
id: "proj-3",
|
||||
userId: OTHER_USER.id,
|
||||
title: "다른 유저 프로젝트",
|
||||
slug: "other-project",
|
||||
status: "DRAFT",
|
||||
};
|
||||
|
||||
inMemoryProjects.push(project1, project2, projectOther);
|
||||
|
||||
const now = new Date("2025-01-03T12:00:00.000Z");
|
||||
const yesterday = new Date("2025-01-02T12:00:00.000Z");
|
||||
const eightDaysAgo = new Date("2024-12-26T12:00:00.000Z");
|
||||
|
||||
inMemoryFormSubmissions.push(
|
||||
{
|
||||
id: "sub-1",
|
||||
projectId: project1.id,
|
||||
projectSlug: project1.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt: now,
|
||||
payloadJson: { message: "A-1" },
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
{
|
||||
id: "sub-2",
|
||||
projectId: project1.id,
|
||||
projectSlug: project1.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt: yesterday,
|
||||
payloadJson: { message: "A-2" },
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
{
|
||||
id: "sub-3",
|
||||
projectId: project2.id,
|
||||
projectSlug: project2.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt: eightDaysAgo,
|
||||
payloadJson: { message: "B-1" },
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
{
|
||||
id: "sub-4",
|
||||
projectId: projectOther.id,
|
||||
projectSlug: projectOther.slug,
|
||||
userId: OTHER_USER.id,
|
||||
createdAt: now,
|
||||
payloadJson: { message: "OTHER" },
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
);
|
||||
|
||||
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const res = await getDashboardOverview(
|
||||
new Request(`${BASE_URL}/api/dashboard/overview`, {
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
|
||||
expect(json.summaryStats.totalProjects).toBe(2);
|
||||
expect(json.summaryStats.totalSubmissions).toBe(3);
|
||||
|
||||
expect(Array.isArray(json.projectStats)).toBe(true);
|
||||
expect(json.projectStats.length).toBe(2);
|
||||
|
||||
const proj1Stats = json.projectStats.find((p: any) => p.slug === project1.slug);
|
||||
const proj2Stats = json.projectStats.find((p: any) => p.slug === project2.slug);
|
||||
|
||||
expect(proj1Stats).toBeTruthy();
|
||||
expect(proj1Stats.totalSubmissions).toBe(2);
|
||||
expect(proj1Stats.lastSubmissionAt).toBe(now.toISOString());
|
||||
|
||||
expect(proj2Stats).toBeTruthy();
|
||||
expect(proj2Stats.totalSubmissions).toBe(1);
|
||||
expect(proj2Stats.lastSubmissionAt).toBe(eightDaysAgo.toISOString());
|
||||
|
||||
const daily = (json as any).dailySubmissions;
|
||||
|
||||
expect(Array.isArray(daily)).toBe(true);
|
||||
expect(daily.length).toBe(3);
|
||||
|
||||
const dates = daily.map((item: any) => item.date).sort();
|
||||
expect(dates).toEqual(["2024-12-26", "2025-01-02", "2025-01-03"]);
|
||||
|
||||
const countsByDate = (daily as any[]).reduce((acc: Record<string, number>, item: any) => {
|
||||
acc[item.date] = item.count;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
expect(countsByDate["2025-01-03"]).toBe(1);
|
||||
expect(countsByDate["2025-01-02"]).toBe(1);
|
||||
expect(countsByDate["2024-12-26"]).toBe(1);
|
||||
});
|
||||
|
||||
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
|
||||
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
|
||||
|
||||
const res = await getDashboardOverview(
|
||||
new Request(`${BASE_URL}/api/dashboard/overview`),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("프로젝트와 제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다", async () => {
|
||||
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const res = await getDashboardOverview(
|
||||
new Request(`${BASE_URL}/api/dashboard/overview`, {
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = (await res.json()) as any;
|
||||
expect(json.summaryStats.totalProjects).toBe(0);
|
||||
expect(json.summaryStats.totalSubmissions).toBe(0);
|
||||
expect(Array.isArray(json.projectStats)).toBe(true);
|
||||
expect(json.projectStats.length).toBe(0);
|
||||
});
|
||||
});
|
||||
+630
-117
@@ -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";
|
||||
@@ -259,6 +261,22 @@ describe("/api/export", () => {
|
||||
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
|
||||
});
|
||||
|
||||
it("bodyBgColorHex 가 빈 문자열이면 buildStaticHtml 의 body style 에 background-color 가 포함되지 않아야 한다", () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "배경색 비어 있음 테스트",
|
||||
slug: "body-bg-empty-test",
|
||||
canvasPreset: "full",
|
||||
canvasBgColorHex: "#0f172a",
|
||||
bodyBgColorHex: "",
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
// body style 에 background-color 가 없어야 한다.
|
||||
expect(html).toContain('<body style="margin:0;padding:0;"');
|
||||
});
|
||||
|
||||
it("projectConfig.headHtml / trackingScript 가 head 와 body 에 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
@@ -531,6 +549,520 @@ describe("/api/export", () => {
|
||||
expect(formHtml).toContain('name="__config"');
|
||||
});
|
||||
|
||||
it("formInput 플로팅 라벨은 Export 에서 placeholder 텍스트와 라벨이 겹치지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_floating_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [],
|
||||
fieldIds: ["field_name"],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "field_name",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
labelDisplay: "floating",
|
||||
// placeholder 가 라벨과 같을 때도 Export 에서는 placeholder 텍스트가 인풋 안에 보이지 않아야 한다.
|
||||
placeholder: "이름",
|
||||
required: true,
|
||||
} as any,
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "플로팅 라벨 Export 테스트",
|
||||
slug: "form-floating-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
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");
|
||||
|
||||
// 플로팅 라벨의 컨테이너는 pb-form-field pb-form-field--floating 클래스를 포함해야 한다.
|
||||
expect(html).toContain("pb-form-field pb-form-field--floating");
|
||||
|
||||
// name="name" 인 input 태그를 찾아 placeholder 를 검사한다.
|
||||
const inputMatch = html.match(/<input[^>]*name=\"name\"[^>]*>/);
|
||||
expect(inputMatch).not.toBeNull();
|
||||
|
||||
const inputTag = inputMatch![0];
|
||||
// placeholder 안에 "이름" 텍스트가 그대로 노출되면 안 된다.
|
||||
expect(inputTag).not.toContain('placeholder="이름"');
|
||||
// 대신 플로팅 라벨 전용으로 공백 placeholder 를 사용한다.
|
||||
expect(inputTag).toContain('placeholder=" "');
|
||||
|
||||
// 플로팅 라벨 컨테이너 내부에서 input 이 label 보다 먼저 나와야 CSS sibling 기반 플로팅이 동작한다.
|
||||
const fieldMatch = html.match(
|
||||
/<div class=\"pb-form-field pb-form-field--floating\">([\s\S]*?)<\/div>/,
|
||||
);
|
||||
expect(fieldMatch).not.toBeNull();
|
||||
const fieldInner = fieldMatch![1];
|
||||
|
||||
const inputIndex = fieldInner.indexOf("<input");
|
||||
const labelIndex = fieldInner.indexOf("<label");
|
||||
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(labelIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(inputIndex).toBeLessThan(labelIndex);
|
||||
});
|
||||
|
||||
it("formSelect 라벨 레이아웃(inline)이 Export HTML 에도 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_select_export_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [],
|
||||
fieldIds: ["select_plan"],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "select_plan",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [
|
||||
{ label: "Basic", value: "basic" },
|
||||
{ label: "Pro", value: "pro" },
|
||||
],
|
||||
labelDisplay: "visible",
|
||||
labelLayout: "inline",
|
||||
labelGapPx: 16,
|
||||
} as any,
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "셀렉트 라벨/레이아웃 Export 테스트",
|
||||
slug: "form-select-layout-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
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");
|
||||
|
||||
// 라벨 표시 방식 visible + inline 레이아웃: pb-form-label 이 정상적으로 노출되어야 한다.
|
||||
expect(html).toMatch(/<label class=\"pb-form-label\"[^>]*>플랜<\/label>/);
|
||||
|
||||
// inline 레이아웃: pb-form-field 컨테이너에 flex-direction:row / align-items:center / column-gap 이 style 로 반영되어야 한다.
|
||||
// labelGapPx: 16px -> 1em
|
||||
const divMatch = html.match(/<div class=\"pb-form-field\"[^>]*style=\"([^\"]*)\"[^>]*>/);
|
||||
expect(divMatch).not.toBeNull();
|
||||
const styleAttr = divMatch![1];
|
||||
expect(styleAttr).toContain("flex-direction:row");
|
||||
expect(styleAttr).toContain("align-items:center");
|
||||
expect(styleAttr).toContain("column-gap:1em");
|
||||
});
|
||||
|
||||
it("formInput 라벨 표시 방식 hidden/floating 이 Export HTML 에도 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_input_hidden_export",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "숨김 라벨",
|
||||
formFieldName: "hidden_label",
|
||||
labelDisplay: "hidden",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "form_input_floating_export",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "플로팅 라벨",
|
||||
formFieldName: "floating_label",
|
||||
labelDisplay: "floating",
|
||||
placeholder: "플로팅 플레이스홀더",
|
||||
paddingY: 16,
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formInput 라벨 표시 방식 Export 테스트",
|
||||
slug: "form-input-label-display-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// hidden: pb-form-label sr-only 로 렌더되어야 한다.
|
||||
expect(html).toMatch(
|
||||
/<div class="pb-form-field">\s*<input class="pb-input"[^>]*name="hidden_label"[^>]*>\s*<label class="pb-form-label sr-only"[^>]*>숨김 라벨<\/label>\s*<\/div>/,
|
||||
);
|
||||
|
||||
// floating: pb-form-field pb-form-field--floating + placeholder=" " + pb-form-label 구조를 사용해야 한다.
|
||||
expect(html).toMatch(
|
||||
/<div class="pb-form-field pb-form-field--floating"[^>]*>\s*<input class="pb-input"[^>]*name="floating_label"[^>]*placeholder=" "[^>]*>\s*<label class="pb-form-label"[^>]*>플로팅 라벨<\/label>\s*<\/div>/,
|
||||
);
|
||||
|
||||
const floatingDivMatch = html.match(
|
||||
/<div class="pb-form-field pb-form-field--floating"([^>]*)>/,
|
||||
);
|
||||
expect(floatingDivMatch).not.toBeNull();
|
||||
expect(floatingDivMatch![1]).toContain('style="--pb-input-padding-y:1rem"');
|
||||
});
|
||||
|
||||
it("formInput Export HTML 은 label 과 input 을 for/id 로 연결해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_input_hidden_export_for_id",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "숨김 라벨",
|
||||
formFieldName: "hidden_label_for_id",
|
||||
labelDisplay: "hidden",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "form_input_floating_export_for_id",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "플로팅 라벨",
|
||||
formFieldName: "floating_label_for_id",
|
||||
labelDisplay: "floating",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formInput for/id Export 테스트",
|
||||
slug: "form-input-for-id-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(html).toMatch(
|
||||
/<input[^>]*name="hidden_label_for_id"[^>]*id="hidden_label_for_id"[^>]*>/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<label[^>]*for="hidden_label_for_id"[^>]*>숨김 라벨<\/label>/,
|
||||
);
|
||||
|
||||
expect(html).toMatch(
|
||||
/<input[^>]*name="floating_label_for_id"[^>]*id="floating_label_for_id"[^>]*>/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/<label[^>]*for="floating_label_for_id"[^>]*>플로팅 라벨<\/label>/,
|
||||
);
|
||||
});
|
||||
|
||||
it("formSelect Export HTML 은 name 과 option value 를 정확히 반영해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "select_export_attrs",
|
||||
type: "formSelect",
|
||||
props: {
|
||||
label: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [
|
||||
{ label: "Basic", value: "basic" },
|
||||
{ label: "Pro", value: "pro" },
|
||||
],
|
||||
required: true,
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formSelect Export 속성 테스트",
|
||||
slug: "form-select-attrs-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(html).toMatch(/<select[^>]*class="pb-select"[^>]*name="plan"[^>]*required[^>]*>/);
|
||||
expect(html).toContain('<option value="basic">Basic<\/option>'.replace("\\/", "/"));
|
||||
expect(html).toContain('<option value="pro">Pro<\/option>'.replace("\\/", "/"));
|
||||
});
|
||||
|
||||
it("formCheckbox/formRadio Export HTML 의 input 은 type/name/value 를 정확히 반영해야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "features_export_attrs",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "기능",
|
||||
formFieldName: "features",
|
||||
options: [
|
||||
{ label: "옵션 1", value: "opt1" },
|
||||
{ label: "옵션 2", value: "opt2" },
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
id: "choices_export_attrs",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "선택",
|
||||
formFieldName: "choice",
|
||||
options: [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "formCheckbox/formRadio Export 속성 테스트",
|
||||
slug: "form-option-attrs-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// 체크박스 옵션 input: type="checkbox" name="features" value="opt1"/"opt2"
|
||||
expect(html).toMatch(/<input type="checkbox"[^>]*name="features"[^>]*value="opt1"[^>]*>/);
|
||||
expect(html).toMatch(/<input type="checkbox"[^>]*name="features"[^>]*value="opt2"[^>]*>/);
|
||||
|
||||
// 라디오 옵션 input: type="radio" name="choice" value="a"/"b"
|
||||
expect(html).toMatch(/<input type="radio"[^>]*name="choice"[^>]*value="a"[^>]*>/);
|
||||
expect(html).toMatch(/<input type="radio"[^>]*name="choice"[^>]*value="b"[^>]*>/);
|
||||
});
|
||||
|
||||
it("FormBlock.requiredFieldIds 는 Export HTML input/select 의 required 속성에도 반영되어야 한다", () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_required_export",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fieldIds: ["field_name", "field_plan"],
|
||||
requiredFieldIds: ["field_name", "field_plan"],
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
id: "field_name",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이름",
|
||||
formFieldName: "name",
|
||||
required: false,
|
||||
} as any,
|
||||
},
|
||||
{
|
||||
id: "field_plan",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "플랜",
|
||||
formFieldName: "plan",
|
||||
options: [
|
||||
{ label: "Basic", value: "basic" },
|
||||
{ label: "Pro", value: "pro" },
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "FormBlock required Export 테스트",
|
||||
slug: "form-required-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
// field_name 은 FormBlock.requiredFieldIds 로 인해 required 이어야 한다.
|
||||
expect(html).toMatch(/<input[^>]*name="name"[^>]*required[^>]*>/);
|
||||
|
||||
// 라디오 그룹에서는 첫 번째 옵션만 required 여야 한다.
|
||||
const firstRadioMatch = html.match(/<input type="radio"[^>]*name="plan"[^>]*value="basic"[^>]*>/);
|
||||
const secondRadioMatch = html.match(/<input type="radio"[^>]*name="plan"[^>]*value="pro"[^>]*>/);
|
||||
|
||||
expect(firstRadioMatch).not.toBeNull();
|
||||
expect(firstRadioMatch![0]).toContain("required");
|
||||
expect(secondRadioMatch).not.toBeNull();
|
||||
expect(secondRadioMatch![0]).not.toContain("required");
|
||||
});
|
||||
|
||||
it("formCheckbox/formRadio 의 optionLayout 값이 Export HTML 의 pb-form-options 컨테이너 클래스로 반영되어야 한다", () => {
|
||||
const checkboxBlocks: Block[] = [
|
||||
{
|
||||
id: "features_export_options",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "기능",
|
||||
formFieldName: "features",
|
||||
options: [
|
||||
{ label: "옵션 1", value: "opt1" },
|
||||
{ label: "옵션 2", value: "opt2" },
|
||||
],
|
||||
optionLayout: "stacked",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const radioBlocks: Block[] = [
|
||||
{
|
||||
id: "choices_export_options",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "선택",
|
||||
formFieldName: "choice",
|
||||
options: [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
],
|
||||
optionLayout: "inline",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "옵션 레이아웃 Export 테스트",
|
||||
slug: "form-option-layout-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const checkboxHtml = buildStaticHtml(checkboxBlocks, projectConfig);
|
||||
expect(checkboxHtml).toContain('class="pb-form-options pb-form-options--stacked"');
|
||||
|
||||
const radioHtml = buildStaticHtml(radioBlocks, projectConfig);
|
||||
expect(radioHtml).toContain('class="pb-form-options pb-form-options--inline"');
|
||||
// 각 옵션 라벨은 pb-form-option 클래스를 사용해야 한다.
|
||||
expect(radioHtml).toContain('class="pb-form-option"');
|
||||
});
|
||||
|
||||
it("formCheckbox/formRadio 그룹 타이틀 표시 방식과 레이아웃이 Export HTML 에도 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_group_export_1",
|
||||
type: "form",
|
||||
props: {
|
||||
kind: "contact",
|
||||
submitTarget: "internal",
|
||||
fields: [],
|
||||
fieldIds: ["features", "choices"],
|
||||
submitButtonId: null,
|
||||
formWidthMode: "full",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "features",
|
||||
type: "formCheckbox",
|
||||
props: {
|
||||
groupLabel: "기능",
|
||||
formFieldName: "features",
|
||||
options: [
|
||||
{ label: "옵션 1", value: "opt1" },
|
||||
{ label: "옵션 2", value: "opt2" },
|
||||
],
|
||||
groupLabelDisplay: "hidden",
|
||||
labelLayout: "inline",
|
||||
labelGapPx: 8,
|
||||
} as any,
|
||||
} as any,
|
||||
{
|
||||
id: "choices",
|
||||
type: "formRadio",
|
||||
props: {
|
||||
groupLabel: "선택",
|
||||
formFieldName: "choice",
|
||||
options: [
|
||||
{ label: "A", value: "a" },
|
||||
{ label: "B", value: "b" },
|
||||
],
|
||||
groupLabelDisplay: "visible",
|
||||
labelLayout: "inline",
|
||||
labelGapPx: 24,
|
||||
} as any,
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "체크박스/라디오 그룹 라벨 Export 테스트",
|
||||
slug: "form-group-layout-export-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
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");
|
||||
|
||||
// 체크박스 그룹: groupLabelDisplay hidden -> pb-form-label sr-only 여야 한다.
|
||||
expect(html).toContain('<label class="pb-form-label sr-only"');
|
||||
|
||||
// 라디오 그룹: inline 레이아웃 + labelGapPx 24px -> column-gap:1.5em, 세로 중앙 정렬이 pb-form-field style 에 반영되어야 한다.
|
||||
const radioDivMatch = html.match(/<div class=\"pb-form-field\"[^>]*style=\"([^\"]*)\"[^>]*>[^]*선택[^]*<label class=\"pb-form-option/);
|
||||
expect(radioDivMatch).not.toBeNull();
|
||||
const radioStyle = radioDivMatch![1];
|
||||
expect(radioStyle).toContain("flex-direction:row");
|
||||
expect(radioStyle).toContain("align-items:center");
|
||||
expect(radioStyle).toContain("column-gap:1.5em");
|
||||
});
|
||||
|
||||
|
||||
it("FormBlock 에 fieldIds/fields 가 모두 없으면 Export 에서 아무 <form>/필드도 생성하지 않아야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
@@ -1483,7 +2015,6 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("Pro");
|
||||
});
|
||||
|
||||
describe("템플릿 섹션 정적 export", () => {
|
||||
function createIdFactory() {
|
||||
let i = 0;
|
||||
return () => `tpl_${++i}`;
|
||||
@@ -1520,145 +2051,77 @@ 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't 형태로 렌더된다.
|
||||
expect(html).toContain("Don'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.");
|
||||
});
|
||||
|
||||
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("스타일 테스트", () => {
|
||||
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");
|
||||
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("기본 텍스트 블록은 프리뷰처럼 흰 글자와 줄바꿈 유지 스타일을 사용해야 한다", async () => {
|
||||
@@ -2031,6 +2494,50 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("섹션 레이아웃 텍스트");
|
||||
});
|
||||
|
||||
it("FormBlock.submitButtonId 로 매핑된 버튼은 해당 form 을 submit 하는 button 요소로 Export 되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
id: "form_1",
|
||||
type: "form",
|
||||
props: {
|
||||
fieldIds: ["field_email"],
|
||||
requiredFieldIds: [],
|
||||
submitButtonId: "submit_btn",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "field_email",
|
||||
type: "formInput",
|
||||
props: {
|
||||
label: "이메일",
|
||||
formFieldName: "email",
|
||||
},
|
||||
} as any,
|
||||
{
|
||||
id: "submit_btn",
|
||||
type: "button",
|
||||
props: {
|
||||
label: "폼 제출",
|
||||
href: "#",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
|
||||
const projectConfig: ProjectConfig = {
|
||||
title: "폼 제출 버튼 매핑 테스트",
|
||||
slug: "form-submit-button-mapping-test",
|
||||
canvasPreset: "full",
|
||||
} as ProjectConfig;
|
||||
|
||||
const { buildStaticHtml } = await import("@/app/api/export/route");
|
||||
|
||||
const html = buildStaticHtml(blocks, projectConfig);
|
||||
|
||||
expect(html).toContain('<form id="form_form_1" class="pb-form-controller" method="post" action="/api/forms/submit"');
|
||||
expect(html).toContain('type="submit" form="form_form_1"');
|
||||
expect(html).toContain("폼 제출");
|
||||
});
|
||||
|
||||
it("formInput 블록의 textColorCustom 은 정적 HTML에서 input 텍스트 색상 스타일로 반영되어야 한다", async () => {
|
||||
const blocks: Block[] = [
|
||||
{
|
||||
@@ -2231,6 +2738,9 @@ describe("/api/export", () => {
|
||||
fillColorCustom: "#123456",
|
||||
strokeColorCustom: "#ff0000",
|
||||
borderRadius: "lg",
|
||||
fontSizeCustom: "24px",
|
||||
lineHeightCustom: "30px",
|
||||
letterSpacingCustom: "2px",
|
||||
},
|
||||
} as any,
|
||||
];
|
||||
@@ -2271,6 +2781,9 @@ describe("/api/export", () => {
|
||||
expect(html).toContain("background-color:#123456");
|
||||
expect(html).toContain("border-color:#ff0000");
|
||||
expect(html).toContain("border-radius:6px");
|
||||
expect(html).toContain("font-size:1.5em");
|
||||
expect(html).toContain("line-height:1.875em");
|
||||
expect(html).toContain("letter-spacing:0.125em");
|
||||
});
|
||||
|
||||
it("formSelect 블록은 스타일 속성으로 셀렉트 배경/보더/패딩/너비/둥글기를 반영해야 한다", async () => {
|
||||
@@ -2658,9 +3171,9 @@ describe("/api/export", () => {
|
||||
expect(css).toContain(".pb-root-inner");
|
||||
expect(css).toContain("max-width: 72rem");
|
||||
expect(css).toContain(".pb-section-inner");
|
||||
expect(css).toContain("margin-inline: auto");
|
||||
expect(css).toContain(".pb-section-columns");
|
||||
expect(css).toContain("gap: 2rem");
|
||||
expect(css).toContain(".pb-section-column");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,8 +98,7 @@ describe("/api/forms/submit", () => {
|
||||
|
||||
// fetch 를 목킹하여 payload 와 헤더가 기대대로 전달되는지 검증한다.
|
||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "test@example.com");
|
||||
@@ -119,7 +118,7 @@ describe("/api/forms/submit", () => {
|
||||
expect(json.ok).toBe(true);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(destinationUrl);
|
||||
expect(options.method).toBe("POST");
|
||||
expect(options.headers).toMatchObject({
|
||||
@@ -152,8 +151,7 @@ describe("/api/forms/submit", () => {
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "json@example.com");
|
||||
@@ -206,8 +204,7 @@ describe("/api/forms/submit", () => {
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("ok", { status: 200 }));
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "success-webhook@example.com");
|
||||
@@ -266,7 +263,7 @@ describe("/api/forms/submit", () => {
|
||||
expect(json.message).toBe(config.successMessage);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
const [url, options] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(url).toBe(destinationUrl);
|
||||
expect(options.method).toBe("POST");
|
||||
});
|
||||
@@ -319,8 +316,7 @@ describe("/api/forms/submit", () => {
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async () => new Response("remote error", { status: 503 }));
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "fail-remote@example.com");
|
||||
@@ -361,8 +357,7 @@ describe("/api/forms/submit", () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("network error");
|
||||
});
|
||||
// @ts-expect-error - 글로벌 fetch 재정의
|
||||
global.fetch = fetchMock;
|
||||
(globalThis as any).fetch = fetchMock;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("email_address", "exception@example.com");
|
||||
|
||||
@@ -16,14 +16,29 @@ vi.mock("@prisma/client", () => {
|
||||
};
|
||||
|
||||
formSubmission = {
|
||||
findMany: async ({ where: { projectId }, orderBy, take }: any) => {
|
||||
let items = inMemoryFormSubmissions.filter((s) => s.projectId === projectId);
|
||||
findMany: async (args: any = {}) => {
|
||||
const where = args.where ?? {};
|
||||
const orderBy = args.orderBy;
|
||||
const take = args.take;
|
||||
|
||||
let items = [...inMemoryFormSubmissions];
|
||||
|
||||
if (where.projectId) {
|
||||
items = items.filter((s) => s.projectId === where.projectId);
|
||||
}
|
||||
|
||||
if (where.userId) {
|
||||
items = items.filter((s) => s.userId === where.userId);
|
||||
}
|
||||
|
||||
if (orderBy?.createdAt === "desc") {
|
||||
items = items.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
if (typeof take === "number") {
|
||||
items = items.slice(0, take);
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
};
|
||||
@@ -169,3 +184,113 @@ describe("/api/projects/[slug]/submissions", () => {
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/api/projects/submissions", () => {
|
||||
it("로그인한 사용자가 자신의 모든 프로젝트 제출 내역을 조회할 수 있어야 한다", async () => {
|
||||
const project1 = {
|
||||
id: "proj-1",
|
||||
slug: "proj-1-slug",
|
||||
userId: TEST_USER.id,
|
||||
};
|
||||
const project2 = {
|
||||
id: "proj-2",
|
||||
slug: "proj-2-slug",
|
||||
userId: OTHER_USER.id,
|
||||
};
|
||||
inMemoryProjects.push(project1, project2);
|
||||
|
||||
const createdAt = new Date();
|
||||
inMemoryFormSubmissions.push(
|
||||
{
|
||||
id: "sub-1",
|
||||
projectId: project1.id,
|
||||
projectSlug: project1.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt,
|
||||
payloadJson: { name: "홍길동" },
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
{
|
||||
id: "sub-2",
|
||||
projectId: project2.id,
|
||||
projectSlug: project2.slug,
|
||||
userId: OTHER_USER.id,
|
||||
createdAt,
|
||||
payloadJson: { name: "다른 유저" },
|
||||
sensitiveEnc: undefined,
|
||||
metaJson: null,
|
||||
},
|
||||
);
|
||||
|
||||
const { GET: getAllSubmissions } = await import("@/app/api/projects/submissions/route");
|
||||
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const res = await getAllSubmissions(
|
||||
new Request(`${BASE_URL}/api/projects/submissions`, {
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any[];
|
||||
expect(Array.isArray(json)).toBe(true);
|
||||
expect(json.length).toBe(1);
|
||||
expect(json[0].id).toBe("sub-1");
|
||||
expect(json[0].projectSlug).toBe(project1.slug);
|
||||
expect(json[0].payload.name).toBe("홍길동");
|
||||
});
|
||||
|
||||
it("민감정보는 전체 조회에서도 복호화된 값으로 payload 에 합쳐져야 한다", async () => {
|
||||
const project = {
|
||||
id: "proj-sensitive",
|
||||
slug: "proj-sensitive",
|
||||
userId: TEST_USER.id,
|
||||
};
|
||||
inMemoryProjects.push(project);
|
||||
|
||||
const { encryptJson } = await import("@/features/auth/authCrypto");
|
||||
const sensitive = { email: "all@example.com", phone: "010-9999-8888" };
|
||||
const sensitiveEnc = await encryptJson(sensitive);
|
||||
|
||||
const createdAt = new Date();
|
||||
inMemoryFormSubmissions.push({
|
||||
id: "sub-sensitive",
|
||||
projectId: project.id,
|
||||
projectSlug: project.slug,
|
||||
userId: TEST_USER.id,
|
||||
createdAt,
|
||||
payloadJson: { name: "전체조회", message: "테스트" },
|
||||
sensitiveEnc,
|
||||
metaJson: null,
|
||||
});
|
||||
|
||||
const { GET: getAllSubmissions } = await import("@/app/api/projects/submissions/route");
|
||||
const headers = await buildAuthHeaders();
|
||||
|
||||
const res = await getAllSubmissions(
|
||||
new Request(`${BASE_URL}/api/projects/submissions`, {
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as any[];
|
||||
expect(json.length).toBe(1);
|
||||
expect(json[0].payload.name).toBe("전체조회");
|
||||
expect(json[0].payload.message).toBe("테스트");
|
||||
expect(json[0].payload.email).toBe("all@example.com");
|
||||
expect(json[0].payload.phone).toBe("010-9999-8888");
|
||||
});
|
||||
|
||||
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
|
||||
const { GET: getAllSubmissions } = await import("@/app/api/projects/submissions/route");
|
||||
|
||||
const res = await getAllSubmissions(
|
||||
new Request(`${BASE_URL}/api/projects/submissions`),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-10
@@ -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,8 @@ test("비로그인 사용자가 /editor 에 접근하면 /login 으로 리다이
|
||||
|
||||
await page.goto("/editor");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
@@ -45,10 +46,11 @@ test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다
|
||||
|
||||
await page.goto("/preview");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
|
||||
test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
|
||||
const email = `e2e+${Date.now()}@example.com`;
|
||||
const password = "securePass1";
|
||||
|
||||
@@ -94,13 +96,14 @@ test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할
|
||||
// 1) 회원가입
|
||||
await page.goto("/signup");
|
||||
|
||||
await page.getByLabel("이메일").fill(email);
|
||||
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();
|
||||
|
||||
// /projects 로 이동했는지, 헤더가 보이는지 확인
|
||||
await expect(page).toHaveURL(/\/projects/);
|
||||
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
|
||||
// /dashboard 로 이동했는지, 헤더가 보이는지 확인
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
|
||||
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
|
||||
await page.goto("/editor");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// 대시보드 E2E TDD
|
||||
// - 비로그인 사용자는 /dashboard 접근 시 /login 으로 리다이렉트되어야 한다.
|
||||
// - 로그인 사용자는 /dashboard 에서 요약 지표 + 프로젝트 카드 리스트를 볼 수 있어야 한다.
|
||||
// - 백엔드/DB 에 의존하지 않도록 /api/dashboard/overview 를 Playwright 라우팅으로 목킹한다.
|
||||
|
||||
test("비로그인 사용자가 /dashboard 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/dashboard/overview", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ message: "로그인이 필요합니다." }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Log in" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로젝트 카드 리스트, 일별 그래프를 볼 수 있어야 한다", async ({ page }) => {
|
||||
await page.route("**/api/dashboard/overview", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summaryStats: {
|
||||
totalProjects: 2,
|
||||
totalSubmissions: 3,
|
||||
todaySubmissions: 1,
|
||||
last7DaysSubmissions: 3,
|
||||
},
|
||||
projectStats: [
|
||||
{
|
||||
projectId: "proj-1",
|
||||
title: "테스트 프로젝트 A",
|
||||
slug: "test-project-a",
|
||||
status: "DRAFT",
|
||||
totalSubmissions: 2,
|
||||
lastSubmissionAt: "2025-01-02T12:00:00.000Z",
|
||||
},
|
||||
{
|
||||
projectId: "proj-2",
|
||||
title: "테스트 프로젝트 B",
|
||||
slug: "test-project-b",
|
||||
status: "PUBLISHED",
|
||||
totalSubmissions: 1,
|
||||
lastSubmissionAt: "2025-01-03T09:30:00.000Z",
|
||||
},
|
||||
],
|
||||
dailySubmissions: [
|
||||
{ date: "2024-12-26", count: 1 },
|
||||
{ date: "2025-01-02", count: 1 },
|
||||
{ date: "2025-01-03", count: 1 },
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/dashboard");
|
||||
|
||||
// 대시보드 헤더가 보여야 한다.
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
|
||||
// 상단 헤더 내비게이션이 보여야 한다.
|
||||
const dashboardLink = page.getByRole("link", { name: "Dashboard" });
|
||||
await expect(dashboardLink).toBeVisible();
|
||||
|
||||
const projectsLink = page.getByRole("link", { name: "Projects" });
|
||||
await expect(projectsLink).toBeVisible();
|
||||
|
||||
const submissionsLink = page.getByRole("link", { name: "All submissions" });
|
||||
await expect(submissionsLink).toBeVisible();
|
||||
|
||||
const menuButton = page.getByRole("button", { name: "Menu" });
|
||||
await expect(menuButton).toBeVisible();
|
||||
|
||||
// 상단 요약 위젯의 지표들이 기대 값으로 렌더되어야 한다.
|
||||
const totalProjects = page.getByTestId("dashboard-summary-total-projects");
|
||||
await expect(totalProjects).toContainText("2");
|
||||
|
||||
const totalSubmissions = page.getByTestId("dashboard-summary-total-submissions");
|
||||
await expect(totalSubmissions).toContainText("3");
|
||||
|
||||
const todaySubmissions = page.getByTestId("dashboard-summary-today-submissions");
|
||||
await expect(todaySubmissions).toContainText("1");
|
||||
|
||||
const last7DaysSubmissions = page.getByTestId(
|
||||
"dashboard-summary-last7days-submissions",
|
||||
);
|
||||
await expect(last7DaysSubmissions).toContainText("3");
|
||||
|
||||
// 프로젝트 카드 리스트가 2개 렌더링되어야 한다.
|
||||
const projectCards = page.getByTestId("dashboard-project-card");
|
||||
await expect(projectCards).toHaveCount(2);
|
||||
|
||||
await expect(projectCards.nth(0)).toContainText("테스트 프로젝트 A");
|
||||
await expect(projectCards.nth(0)).toContainText("test-project-a");
|
||||
await expect(projectCards.nth(0)).toContainText("2");
|
||||
|
||||
await expect(projectCards.nth(1)).toContainText("테스트 프로젝트 B");
|
||||
await expect(projectCards.nth(1)).toContainText("test-project-b");
|
||||
await expect(projectCards.nth(1)).toContainText("1");
|
||||
|
||||
// 일별 제출 수 그래프가 렌더링되어야 한다.
|
||||
const dailyChart = page.getByTestId("dashboard-daily-chart");
|
||||
await expect(dailyChart).toBeVisible();
|
||||
|
||||
const dailyBars = page.getByTestId("dashboard-daily-chart-bar");
|
||||
await expect(dailyBars).toHaveCount(3);
|
||||
|
||||
// 메뉴 버튼을 클릭하면 로그아웃 항목이 보여야 한다.
|
||||
await menuButton.click();
|
||||
await expect(page.getByRole("button", { name: "Log out" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("로그인 사용자가 / 에 접속하면 대시보드로 진입해야 한다", async ({ page }) => {
|
||||
await page.route("**/api/dashboard/overview", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
summaryStats: {
|
||||
totalProjects: 1,
|
||||
totalSubmissions: 0,
|
||||
todaySubmissions: 0,
|
||||
last7DaysSubmissions: 0,
|
||||
},
|
||||
projectStats: [],
|
||||
dailySubmissions: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
|
||||
});
|
||||
+250
-241
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user