테마 적용
This commit is contained in:
@@ -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` : "";
|
||||
|
||||
@@ -460,12 +462,19 @@ export const buildStaticHtml = (blocks: Block[], projectConfig?: ProjectConfig):
|
||||
const inputId = name;
|
||||
|
||||
const fieldClass = isFloating ? "pb-form-field pb-form-field--floating" : "pb-form-field";
|
||||
let fieldStyleAttr = "";
|
||||
const cssVars: string[] = [];
|
||||
if (isFloating && typeof props.paddingY === "number" && props.paddingY >= 0) {
|
||||
const em = props.paddingY / 16;
|
||||
fieldStyleAttr = ` style="--pb-input-padding-y:${em}rem"`;
|
||||
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()
|
||||
|
||||
@@ -70,7 +70,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) => {
|
||||
@@ -118,11 +118,11 @@ interface DragPreviewProps {
|
||||
block: Block | null;
|
||||
}
|
||||
|
||||
function DragPreview({ block }: DragPreviewProps) {
|
||||
export function EditorCanvasDragPreview({ block }: DragPreviewProps) {
|
||||
if (!block) return null;
|
||||
|
||||
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"
|
||||
@@ -152,3 +152,7 @@ function DragPreview({ block }: DragPreviewProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DragPreview({ block }: DragPreviewProps) {
|
||||
return <EditorCanvasDragPreview block={block} />;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
|
||||
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">체크박스 필드</h3>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">그룹 타이틀 타입</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, {
|
||||
@@ -37,9 +37,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 표시 방식</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">그룹 타이틀 표시 방식</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={groupLabelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -53,10 +53,10 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={checkboxProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -70,10 +70,10 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={checkboxProps.optionLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -109,9 +109,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">그룹 타이틀</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, {
|
||||
@@ -133,7 +133,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 소스</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, {
|
||||
@@ -150,7 +150,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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, {
|
||||
@@ -208,9 +208,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">전송 키</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, {
|
||||
@@ -222,10 +222,10 @@ 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">옵션</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]
|
||||
@@ -244,9 +244,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</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">옵션 이미지 소스</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];
|
||||
@@ -266,9 +266,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
|
||||
{(((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"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="라벨"
|
||||
value={opt.label ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -283,7 +283,7 @@ 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"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="값(value)"
|
||||
value={opt.value ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -299,7 +299,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, {
|
||||
@@ -320,7 +320,7 @@ 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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -337,7 +337,7 @@ 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">옵션 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -397,7 +397,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
</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">필드 스타일</h4>
|
||||
{/* px 기반 체크박스 타이포 입력값을 노출하여 em 스케일 변환을 위한 근거를 남긴다 */}
|
||||
<NumericPropertyControl
|
||||
label="체크박스 텍스트 크기 (px)"
|
||||
@@ -475,7 +475,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
<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"
|
||||
value={checkboxProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -574,7 +574,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
value={
|
||||
checkboxProps.textColorCustom && checkboxProps.textColorCustom.trim() !== ""
|
||||
? checkboxProps.textColorCustom
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -595,7 +595,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
value={
|
||||
checkboxProps.fillColorCustom && checkboxProps.fillColorCustom.trim() !== ""
|
||||
? checkboxProps.fillColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -616,7 +616,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
|
||||
value={
|
||||
checkboxProps.strokeColorCustom && checkboxProps.strokeColorCustom.trim() !== ""
|
||||
? checkboxProps.strokeColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
|
||||
@@ -76,10 +76,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
</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">전송 대상</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, {
|
||||
@@ -90,14 +90,14 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
<option value="internal">서버 처리 전용 (Webhook 없이 사용)</option>
|
||||
<option value="webhook">외부 Webhook / Google Sheets 로 전달</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formProps.submitTarget === "webhook" && (
|
||||
<div className="space-y-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">Webhook URL (예: Google Apps Script 웹 앱 URL)</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">Webhook URL (예: Google Apps Script 웹 앱 URL)</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"
|
||||
placeholder="예: https://script.google.com/macros/s/.../exec"
|
||||
value={formProps.destinationUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -108,9 +108,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">전송 포맷</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, {
|
||||
@@ -123,9 +123,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
</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">HTTP 메서드</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, {
|
||||
@@ -138,9 +138,9 @@ 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">Authorization 토큰 (옵션)</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"
|
||||
placeholder="예: Bearer xxxxxx"
|
||||
value={formProps.headers?.Authorization ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -154,9 +154,9 @@ 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">추가 파라미터 (key=value 형식, 줄바꿈으로 구분)</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"
|
||||
className="w-full h-16 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder={"예:\nsource=landing\nformId=contact-hero"}
|
||||
value={Object.entries(formProps.extraParams ?? {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
@@ -183,7 +183,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-900 px-2 py-1 text-[11px] text-slate-100 hover:bg-slate-800"
|
||||
className="inline-flex items-center gap-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={() => setShowSheetsGuide(true)}
|
||||
>
|
||||
Google Sheets 연동 가이드
|
||||
@@ -194,11 +194,11 @@ 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">
|
||||
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">폼 레이아웃</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark: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"
|
||||
value={formProps.formWidthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -253,11 +253,11 @@ 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">
|
||||
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">폼 메시지</h3>
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span>성공 메시지</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"
|
||||
placeholder="예: 성공적으로 전송되었습니다."
|
||||
value={formProps.successMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -267,10 +267,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<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>에러 메시지</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"
|
||||
placeholder="예: 전송 중 오류가 발생했습니다."
|
||||
value={formProps.errorMessage ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -283,7 +283,7 @@ 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">폼 컨트롤러</h3>
|
||||
|
||||
<fieldset className="space-y-2" aria-label="폼 필드 매핑">
|
||||
<legend className="text-[11px] text-slate-400 mb-1">폼 필드 매핑</legend>
|
||||
@@ -313,11 +313,11 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
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 ?? [];
|
||||
@@ -334,7 +334,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
<label className="inline-flex items-center gap-1 text-[11px] text-slate-400">
|
||||
<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={required}
|
||||
onChange={(e) => {
|
||||
const current = formProps.requiredFieldIds ?? [];
|
||||
@@ -357,7 +357,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] text-slate-400">Submit 버튼</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="Submit 버튼"
|
||||
value={formProps.submitButtonId ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -390,10 +390,10 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
</div>
|
||||
|
||||
{showSheetsGuide && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70">
|
||||
<div className="w-full max-w-xl rounded border border-slate-800 bg-slate-950 px-4 py-3 shadow-lg">
|
||||
<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 text-slate-100">Google Sheets 연동 가이드</h4>
|
||||
<h4 className="text-[11px] font-semibold">Google Sheets 연동 가이드</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[11px] text-slate-400 hover:text-slate-100"
|
||||
@@ -403,7 +403,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
<p className="text-[11px] text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
구글 시트 주소를 그대로 입력하는 것이 아니라, 시트에 연결된 Google Apps Script 웹 앱 URL 을 입력해야 합니다.
|
||||
아래 순서를 따르면 코드를 잘 모르는 사용자도 연동할 수 있습니다.
|
||||
</p>
|
||||
@@ -413,9 +413,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
|
||||
<li>"배포 → 새 배포" 에서 웹 앱으로 배포한 뒤, 발급된 웹 앱 URL(…/exec) 을 Webhook URL 칸에 붙여넣습니다.</li>
|
||||
</ol>
|
||||
<div className="mt-2 space-y-1">
|
||||
<span className="text-[11px] text-slate-400">예시 Apps Script 코드</span>
|
||||
<span className="text-[11px] text-slate-500 dark:text-slate-400">예시 Apps Script 코드</span>
|
||||
<textarea
|
||||
className="w-full h-40 rounded border border-slate-800 bg-slate-950 px-2 py-1 text-[11px] font-mono text-slate-200"
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -18,11 +18,11 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
|
||||
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">입력 필드</h3>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">라벨 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">라벨 타입</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, {
|
||||
@@ -35,9 +35,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">필드 라벨</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">필드 라벨</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, {
|
||||
@@ -47,9 +47,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">라벨 표시 방식</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={labelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -63,10 +63,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</select>
|
||||
</label>
|
||||
{labelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={inputProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -103,9 +103,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
{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">라벨 이미지 URL</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, {
|
||||
@@ -115,9 +115,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">라벨 이미지 대체 텍스트</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, {
|
||||
@@ -129,9 +129,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">전송 키</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, {
|
||||
@@ -141,9 +141,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">필드 타입</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="필드 타입"
|
||||
value={inputProps.inputType ?? "text"}
|
||||
onChange={(e) =>
|
||||
@@ -158,9 +158,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</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">Placeholder</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"
|
||||
aria-label="Placeholder"
|
||||
value={inputProps.placeholder ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -171,10 +171,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<span className="text-slate-500">필수 여부는 폼 컨트롤러에서 설정합니다.</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">필수 여부는 폼 컨트롤러에서 설정합니다.</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">필드 스타일</h4>
|
||||
<NumericPropertyControl
|
||||
label="필드 텍스트 크기 (px)"
|
||||
unitLabel="(px)"
|
||||
@@ -248,10 +248,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={inputProps.align ?? "left"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -264,10 +264,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<option value="right">오른쪽</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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="너비"
|
||||
value={inputProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
@@ -346,7 +346,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
value={
|
||||
inputProps.textColorCustom && inputProps.textColorCustom.trim() !== ""
|
||||
? inputProps.textColorCustom
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -367,7 +367,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
value={
|
||||
inputProps.fillColorCustom && inputProps.fillColorCustom.trim() !== ""
|
||||
? inputProps.fillColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -388,7 +388,7 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
value={
|
||||
inputProps.strokeColorCustom && inputProps.strokeColorCustom.trim() !== ""
|
||||
? inputProps.strokeColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
|
||||
@@ -18,12 +18,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
|
||||
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">라디오 필드</h3>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">그룹 타이틀 타입</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, {
|
||||
@@ -37,9 +37,9 @@ export function FormRadioPropertiesPanel({ 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">그룹 타이틀 표시 방식</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={groupLabelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -53,10 +53,10 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</label>
|
||||
|
||||
{groupLabelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={radioProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -70,10 +70,10 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={radioProps.optionLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -109,9 +109,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">그룹 타이틀</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, {
|
||||
@@ -133,7 +133,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 소스</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, {
|
||||
@@ -150,7 +150,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">그룹 타이틀 이미지 URL</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, {
|
||||
@@ -208,9 +208,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">전송 키</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, {
|
||||
@@ -222,10 +222,10 @@ 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">옵션</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((radioProps as any).options)
|
||||
? [...(radioProps as any).options]
|
||||
@@ -244,9 +244,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</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">옵션 이미지 소스</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];
|
||||
@@ -266,9 +266,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
|
||||
{(((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"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="라벨"
|
||||
value={opt.label ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -283,7 +283,7 @@ 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"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="값(value)"
|
||||
value={opt.value ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -299,7 +299,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, {
|
||||
@@ -320,7 +320,7 @@ 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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="옵션 이미지 URL (선택)"
|
||||
value={opt.labelImageUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -337,7 +337,7 @@ 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">옵션 이미지 파일 업로드</span>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@@ -397,7 +397,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
</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">필드 스타일</h4>
|
||||
{/* 라디오 타이포 px 값을 직접 입력받아 em 스케일 변환 로직과 연동한다 */}
|
||||
<NumericPropertyControl
|
||||
label="라디오 텍스트 크기 (px)"
|
||||
@@ -475,7 +475,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
<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"
|
||||
value={radioProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -574,7 +574,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
value={
|
||||
radioProps.textColorCustom && radioProps.textColorCustom.trim() !== ""
|
||||
? radioProps.textColorCustom
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -595,7 +595,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
value={
|
||||
radioProps.fillColorCustom && radioProps.fillColorCustom.trim() !== ""
|
||||
? radioProps.fillColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -616,7 +616,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
|
||||
value={
|
||||
radioProps.strokeColorCustom && radioProps.strokeColorCustom.trim() !== ""
|
||||
? radioProps.strokeColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
|
||||
@@ -18,12 +18,12 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
|
||||
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">셀렉트 필드</h3>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">라벨 타입</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">라벨 타입</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, {
|
||||
@@ -37,9 +37,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">라벨 표시 방식</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={labelDisplay}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -53,10 +53,10 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
</label>
|
||||
|
||||
{labelDisplay === "visible" && (
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={selectProps.labelLayout ?? "stacked"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -93,9 +93,9 @@ export function FormSelectPropertiesPanel({ 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">필드 라벨</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, {
|
||||
@@ -106,9 +106,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">전송 키</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, {
|
||||
@@ -120,10 +120,10 @@ 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">옵션</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]
|
||||
@@ -143,9 +143,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
<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"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="라벨"
|
||||
value={opt.label ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -160,7 +160,7 @@ 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"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
placeholder="값(value)"
|
||||
value={opt.value ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -176,7 +176,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, {
|
||||
@@ -196,7 +196,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
<span className="text-slate-500">필수 여부는 폼 컨트롤러에서 설정합니다.</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">필드 스타일</h4>
|
||||
<NumericPropertyControl
|
||||
label="셀렉트 텍스트 크기 (px)"
|
||||
unitLabel="(px)"
|
||||
@@ -270,10 +270,10 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
} as any);
|
||||
}}
|
||||
/>
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-400">
|
||||
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark: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"
|
||||
value={selectProps.widthMode ?? "full"}
|
||||
onChange={(e) =>
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -353,7 +353,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
value={
|
||||
selectProps.textColorCustom && selectProps.textColorCustom.trim() !== ""
|
||||
? selectProps.textColorCustom
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -374,7 +374,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
value={
|
||||
selectProps.fillColorCustom && selectProps.fillColorCustom.trim() !== ""
|
||||
? selectProps.fillColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -395,7 +395,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
|
||||
value={
|
||||
selectProps.strokeColorCustom && selectProps.strokeColorCustom.trim() !== ""
|
||||
? selectProps.strokeColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
|
||||
+44
-37
@@ -1070,8 +1070,8 @@ function EditorPageInner() {
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="h-screen flex flex-col overflow-hidden">
|
||||
<header className="border-b border-slate-800 px-6 py-4 flex items-center justify-between relative z-20 bg-slate-950/80 backdrop-blur">
|
||||
<main className="h-screen flex flex-col overflow-hidden 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 relative z-20 bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-slate-900 border border-sky-700 shadow-sm">
|
||||
<Pencil className="w-4 h-4 text-sky-400" aria-hidden="true" />
|
||||
@@ -1233,12 +1233,12 @@ function EditorPageInner() {
|
||||
|
||||
{activeModal === "project" && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-md rounded-lg border border-slate-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-3">
|
||||
<h3 className="text-sm font-medium">프로젝트 저장 / 불러오기</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-100 text-sm"
|
||||
className="text-slate-400 hover:text-slate-700 text-sm dark:hover:text-slate-100"
|
||||
onClick={() => setActiveModal(null)}
|
||||
>
|
||||
✕
|
||||
@@ -1246,17 +1246,17 @@ function EditorPageInner() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">프로젝트 제목</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">프로젝트 제목</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={projectConfig.title ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">프로젝트 주소 (예: my-landing)</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">프로젝트 주소 (예: my-landing)</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={projectConfig.slug ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
|
||||
/>
|
||||
@@ -1264,21 +1264,21 @@ function EditorPageInner() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-sky-700 bg-sky-950 px-2 py-1 text-sky-100 hover:bg-sky-900"
|
||||
className="flex-1 rounded border border-sky-500 bg-sky-50 px-2 py-1 text-sky-700 hover:bg-sky-100 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
|
||||
onClick={handleSaveProject}
|
||||
>
|
||||
저장 (로컬 + 서버)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-100 hover:bg-slate-800"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={handleLoadProject}
|
||||
>
|
||||
불러오기
|
||||
</button>
|
||||
</div>
|
||||
{projectMessage && (
|
||||
<p className="text-[11px] text-slate-300 mt-1">{projectMessage}</p>
|
||||
<p className="text-[11px] text-slate-600 mt-1 dark:text-slate-300">{projectMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1289,12 +1289,17 @@ function EditorPageInner() {
|
||||
|
||||
{activeModal === "json" && (
|
||||
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-2xl rounded-lg border border-slate-700 bg-slate-900 p-4 text-xs text-slate-100 shadow-xl">
|
||||
<div className="w-full max-w-2xl rounded-lg border border-slate-200 bg-white p-4 text-xs text-slate-900 shadow-xl dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium">JSON Export / Import <span className="text-xs text-slate-400">* 에디터 내용을 가져오거나 내보낼 수 있습니다.</span></h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
JSON Export / Import
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
* 에디터 내용을 가져오거나 내보낼 수 있습니다.
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-400 hover:text-slate-100 text-sm"
|
||||
className="text-slate-400 hover:text-slate-700 text-sm dark:hover:text-slate-100"
|
||||
onClick={() => setActiveModal(null)}
|
||||
>
|
||||
✕
|
||||
@@ -1304,14 +1309,14 @@ function EditorPageInner() {
|
||||
<div className="flex gap-2 mb-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 hover:bg-slate-800"
|
||||
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={handleExportJson}
|
||||
>
|
||||
JSON 내보내기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-red-700 bg-red-950 px-2 py-1 text-red-100 hover:bg-red-900"
|
||||
className="rounded border border-red-300 bg-white px-2 py-1 text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-red-950 dark:text-red-100 dark:hover:bg-red-900"
|
||||
onClick={handleClearCanvas}
|
||||
>
|
||||
캔버스 초기화
|
||||
@@ -1319,23 +1324,23 @@ function EditorPageInner() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">에디터 상태 JSON</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">에디터 상태 JSON</span>
|
||||
<textarea
|
||||
className="w-full h-48 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
|
||||
className="w-full h-48 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
value={exportJson}
|
||||
readOnly
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">JSON에서 불러오기</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">JSON에서 불러오기</span>
|
||||
<textarea
|
||||
className="w-full h-48 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[10px] font-mono outline-none focus:border-sky-500"
|
||||
className="w-full h-48 rounded border border-slate-300 bg-white px-2 py-1 text-[10px] font-mono text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
value={importJson}
|
||||
onChange={(e) => setImportJson(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 w-full rounded border border-slate-700 bg-slate-950 px-2 py-1 hover:bg-slate-800"
|
||||
className="mt-1 w-full rounded border border-slate-300 bg-white px-2 py-1 text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
onClick={handleApplyImportJson}
|
||||
>
|
||||
JSON 적용하기
|
||||
@@ -1509,8 +1514,10 @@ function SortableEditorBlock({
|
||||
data-selected={isSelected ? "true" : "false"}
|
||||
aria-selected={isSelected ? "true" : "false"}
|
||||
className={`rounded border px-3 py-2 cursor-text transition-colors ${alignClass} ${sizeClass} ${leadingClass} ${weightClass} ${colorClass} ${maxWidthClass} ${decorationClass} ${
|
||||
isSelected ? "border-sky-500 bg-slate-900/80" : "border-slate-700 bg-slate-900"
|
||||
}`}
|
||||
isSelected
|
||||
? "border-sky-500 bg-sky-50 text-slate-900 dark:bg-slate-900/80 dark:text-slate-100"
|
||||
: "border-slate-200 bg-white text-slate-900 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
// 멀티 선택(MVP): Cmd/Ctrl+클릭 시 선택 토글, 그 외에는 단일 선택으로 전환한다.
|
||||
const isMeta = event.metaKey || event.ctrlKey;
|
||||
@@ -1541,7 +1548,7 @@ function SortableEditorBlock({
|
||||
<div className="flex items-start gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 h-4 w-4 rounded border border-slate-700 bg-slate-900 text-[10px] flex items-center justify-center text-slate-400 hover:bg-slate-800"
|
||||
className="mt-0.5 h-4 w-4 rounded border border-slate-300 bg-slate-50 text-[10px] flex items-center justify-center text-slate-500 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||
aria-label="블록 드래그 핸들"
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
@@ -1610,7 +1617,7 @@ function SortableEditorBlock({
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200 shrink-0">{inputProps.label}</span>
|
||||
<span className="shrink-0">{inputProps.label}</span>
|
||||
)
|
||||
)}
|
||||
{(() => {
|
||||
@@ -1715,7 +1722,7 @@ function SortableEditorBlock({
|
||||
className="h-4 w-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200">{selectProps.label}</span>
|
||||
<span>{selectProps.label}</span>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<select
|
||||
@@ -1792,14 +1799,14 @@ function SortableEditorBlock({
|
||||
className="inline-block max-w-full h-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200">{radioProps.groupLabel}</span>
|
||||
<span>{radioProps.groupLabel}</span>
|
||||
)}
|
||||
<div style={optionsStyle} className={optionsLayoutClass}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="pb-form-option">
|
||||
<input
|
||||
type="radio"
|
||||
className="h-4 w-4 border-slate-700 bg-slate-900 text-sky-500"
|
||||
className="h-4 w-4"
|
||||
name={radioProps.formFieldName}
|
||||
value={opt.value}
|
||||
readOnly
|
||||
@@ -1863,8 +1870,8 @@ function SortableEditorBlock({
|
||||
: { rowGap: `${optionGapPx}px` }
|
||||
: {};
|
||||
const groupContainerClassName = isInlineLayout
|
||||
? "flex flex-row items-center text-xs text-slate-200"
|
||||
: "flex flex-col gap-1 text-xs text-slate-200";
|
||||
? "flex flex-row items-center text-xs"
|
||||
: "flex flex-col gap-1 text-xs";
|
||||
|
||||
return (
|
||||
<div className={groupContainerClassName} style={groupContainerStyle}>
|
||||
@@ -1877,14 +1884,14 @@ function SortableEditorBlock({
|
||||
className="inline-block max-w-full h-auto"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-slate-200">{checkboxProps.groupLabel}</span>
|
||||
<span>{checkboxProps.groupLabel}</span>
|
||||
)}
|
||||
<div className={optionsLayoutClass} style={optionsStyle}>
|
||||
{options.map((opt) => (
|
||||
<label key={opt.value} className="pb-form-option">
|
||||
<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={checkboxProps.formFieldName}
|
||||
value={opt.value}
|
||||
readOnly
|
||||
@@ -2050,7 +2057,7 @@ function SortableEditorBlock({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full border border-dashed border-slate-700 bg-slate-900/40 rounded flex items-center overflow-hidden ${alignClass}`}
|
||||
className={`w-full border border-dashed border-slate-700 bg-slate-500/40 rounded flex items-center overflow-hidden ${alignClass}`}
|
||||
>
|
||||
{hasSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -2061,7 +2068,7 @@ function SortableEditorBlock({
|
||||
style={{ ...containerStyle, ...imageStyle }}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-slate-500 px-2 py-4">
|
||||
<span className="text-[10px] text-slate-900 dark:text-slate-500 px-2 py-4">
|
||||
이미지 URL 을 입력하거나 파일을 업로드하면 여기에서 미리보기가 표시됩니다.
|
||||
</span>
|
||||
)}
|
||||
@@ -2162,7 +2169,7 @@ function SortableEditorBlock({
|
||||
style={index < nodes.length - 1 ? { marginBottom: gapPx } : undefined}
|
||||
>
|
||||
<span className="break-words align-middle">{node.text}</span>
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[10px] text-slate-300 align-middle">
|
||||
<span className="ml-2 inline-flex items-center gap-1 text-[10px] text-slate-900 dark:text-slate-300 align-middle">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-slate-700 px-1 py-0.5 hover:bg-slate-800"
|
||||
@@ -2367,13 +2374,13 @@ function ColumnDroppable({ id, sectionId, columnId, basis, blocks, renderBlocks,
|
||||
style={{ flexBasis: basis }}
|
||||
>
|
||||
<div
|
||||
className={`border rounded flex flex-col gap-2 items-stretch justify-start px-2 py-2 bg-slate-950/40 ${
|
||||
className={`border rounded flex flex-col gap-2 items-stretch justify-start px-2 py-2 bg-slate-100/40 dark:bg-slate-950/40 ${
|
||||
isActiveColumn ? "border-sky-500" : "border-slate-800/80"
|
||||
}`}
|
||||
style={{}}
|
||||
>
|
||||
{blocks.length === 0 ? (
|
||||
<span className="text-[11px] px-2 text-center text-slate-500">
|
||||
<span className="text-[11px] px-2 text-center text-slate-900 dark:text-slate-300">
|
||||
{`컬럼 영역 (span ${span}/12)`}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -90,8 +90,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"
|
||||
@@ -108,7 +111,7 @@ 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">
|
||||
@@ -118,7 +121,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -128,7 +131,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -138,7 +141,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -148,7 +151,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -158,7 +161,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -168,7 +171,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -179,8 +182,8 @@ export function BlocksSidebar() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<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"
|
||||
@@ -197,7 +200,7 @@ 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">
|
||||
@@ -207,7 +210,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -217,7 +220,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -227,7 +230,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -237,7 +240,7 @@ export function BlocksSidebar() {
|
||||
</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">
|
||||
@@ -249,8 +252,8 @@ export function BlocksSidebar() {
|
||||
)}
|
||||
</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"
|
||||
@@ -267,20 +270,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">히어로 · CTA</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 템플릿
|
||||
</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" />
|
||||
@@ -294,18 +297,18 @@ export function BlocksSidebar() {
|
||||
</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 템플릿
|
||||
</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]">
|
||||
@@ -322,20 +325,20 @@ export function BlocksSidebar() {
|
||||
</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">콘텐츠 섹션</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}
|
||||
>
|
||||
기능 템플릿
|
||||
</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" />
|
||||
@@ -354,18 +357,18 @@ export function BlocksSidebar() {
|
||||
<p className="text-[10px] text-slate-400 leading-snug">3컬럼 기능 소개 섹션</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 템플릿
|
||||
</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" />
|
||||
@@ -380,18 +383,18 @@ export function BlocksSidebar() {
|
||||
<p className="text-[10px] text-slate-400 leading-snug">자주 묻는 질문(FAQ) 섹션</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}
|
||||
>
|
||||
상품 템플릿
|
||||
</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" />
|
||||
@@ -411,18 +414,18 @@ export function BlocksSidebar() {
|
||||
<p className="text-[10px] text-slate-400 leading-snug">요금제/플랜 소개 섹션</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}
|
||||
>
|
||||
블로그 템플릿
|
||||
</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]" />
|
||||
@@ -447,20 +450,20 @@ export function BlocksSidebar() {
|
||||
</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">신뢰/소개</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}
|
||||
>
|
||||
후기 템플릿
|
||||
</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" />
|
||||
@@ -479,18 +482,18 @@ export function BlocksSidebar() {
|
||||
<p className="text-[10px] text-slate-400 leading-snug">고객 후기(Testimonials) 섹션</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 템플릿
|
||||
</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" />
|
||||
@@ -515,20 +518,20 @@ export function BlocksSidebar() {
|
||||
</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">푸터/기타</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 템플릿
|
||||
</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]">
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>버튼 텍스트</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"
|
||||
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="버튼 텍스트"
|
||||
value={buttonProps.label}
|
||||
onChange={(e) => {
|
||||
@@ -34,13 +34,13 @@ 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">버튼 이미지</h4>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>버튼 이미지 소스</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"
|
||||
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="버튼 이미지 소스"
|
||||
value={imageSource}
|
||||
onChange={(e) => {
|
||||
@@ -74,7 +74,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>버튼 이미지 URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="버튼 이미지 URL"
|
||||
value={buttonProps.imageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -142,7 +142,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>버튼 이미지 대체 텍스트</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"
|
||||
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="버튼 이미지 대체 텍스트"
|
||||
value={buttonProps.imageAlt ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -156,7 +156,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>버튼 이미지 위치</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="버튼 이미지 위치"
|
||||
value={buttonProps.imagePlacement ?? "left"}
|
||||
onChange={(e) => {
|
||||
@@ -223,7 +223,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>버튼 링크</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"
|
||||
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="버튼 링크"
|
||||
value={buttonProps.href}
|
||||
onChange={(e) => {
|
||||
@@ -236,7 +236,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>정렬</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="버튼 정렬"
|
||||
value={buttonProps.align ?? "left"}
|
||||
onChange={(e) => {
|
||||
@@ -259,9 +259,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
ariaLabelColorInput="버튼 텍스트 색상 피커"
|
||||
ariaLabelHexInput="버튼 텍스트 색상 HEX"
|
||||
value={
|
||||
buttonProps.textColorCustom && buttonProps.textColorCustom.startsWith("#")
|
||||
buttonProps.textColorCustom && buttonProps.textColorCustom.trim() !== ""
|
||||
? buttonProps.textColorCustom
|
||||
: "#f9fafb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -280,7 +280,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>스타일</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="버튼 스타일"
|
||||
value={buttonProps.variant ?? "solid"}
|
||||
onChange={(e) => {
|
||||
@@ -300,9 +300,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
ariaLabelColorInput="버튼 채움 색상 피커"
|
||||
ariaLabelHexInput="버튼 채움 색상 HEX"
|
||||
value={
|
||||
buttonProps.fillColorCustom && buttonProps.fillColorCustom.startsWith("#")
|
||||
buttonProps.fillColorCustom && buttonProps.fillColorCustom.trim() !== ""
|
||||
? buttonProps.fillColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -323,9 +323,9 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
ariaLabelColorInput="버튼 외곽선 색상 피커"
|
||||
ariaLabelHexInput="버튼 외곽선 색상 HEX"
|
||||
value={
|
||||
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.startsWith("#")
|
||||
buttonProps.strokeColorCustom && buttonProps.strokeColorCustom.trim() !== ""
|
||||
? buttonProps.strokeColorCustom
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#0ea5e9"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, {
|
||||
@@ -368,7 +368,7 @@ export function ButtonPropertiesPanel({ buttonProps, selectedBlockId, updateBloc
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>버튼 너비 모드</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="버튼 너비 모드"
|
||||
value={buttonProps.widthMode ?? (buttonProps.fullWidth ? "full" : "auto")}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>정렬</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="구분선 정렬"
|
||||
value={dividerProps.align}
|
||||
onChange={(e) => {
|
||||
@@ -35,7 +35,7 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>두께</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="구분선 두께"
|
||||
value={dividerProps.thickness}
|
||||
onChange={(e) => {
|
||||
@@ -50,13 +50,13 @@ export function DividerPropertiesPanel({ dividerProps, 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-800 dark:text-slate-200">구분선 스타일</h4>
|
||||
|
||||
{/* 길이/너비 모드 */}
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>길이 모드</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="구분선 길이 모드"
|
||||
value={dividerProps.widthMode ?? "full"}
|
||||
onChange={(e) => {
|
||||
@@ -97,7 +97,7 @@ export function DividerPropertiesPanel({ dividerProps, selectedBlockId, updateBl
|
||||
value={
|
||||
dividerProps.colorHex && dividerProps.colorHex.trim() !== ""
|
||||
? dividerProps.colorHex
|
||||
: TEXT_COLOR_PALETTE[0]?.color ?? "#64748b"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { colorHex: hex } as any);
|
||||
|
||||
@@ -22,7 +22,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>이미지 소스</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"
|
||||
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="이미지 소스"
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
@@ -50,7 +50,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>이미지 URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="이미지 URL"
|
||||
value={imageProps.src}
|
||||
onChange={(e) => {
|
||||
@@ -115,7 +115,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>대체 텍스트</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"
|
||||
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="대체 텍스트"
|
||||
value={imageProps.alt}
|
||||
onChange={(e) => {
|
||||
@@ -126,7 +126,7 @@ 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">이미지 스타일</h4>
|
||||
|
||||
{/* 카드 배경색 */}
|
||||
<div className="space-y-1">
|
||||
@@ -147,7 +147,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>정렬</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"
|
||||
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="이미지 정렬"
|
||||
value={imageProps.align ?? "center"}
|
||||
onChange={(e) =>
|
||||
@@ -166,7 +166,7 @@ export function ImagePropertiesPanel({ imageProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>너비 모드</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"
|
||||
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="이미지 너비 모드"
|
||||
value={imageProps.widthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ListPropertiesPanel({
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>리스트 아이템 (줄바꿈으로 구분)</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"
|
||||
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="리스트 아이템들"
|
||||
value={(() => {
|
||||
const tree = (listProps as any).itemsTree as any[] | undefined;
|
||||
@@ -85,7 +85,7 @@ export function ListPropertiesPanel({
|
||||
<label className="flex items-center gap-2">
|
||||
<span>정렬</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="리스트 정렬"
|
||||
value={listProps.align}
|
||||
onChange={(e) => {
|
||||
@@ -101,7 +101,7 @@ export function ListPropertiesPanel({
|
||||
</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">리스트 스타일</h4>
|
||||
|
||||
{/* 글자 크기 */}
|
||||
<NumericPropertyControl
|
||||
@@ -153,10 +153,12 @@ export function ListPropertiesPanel({
|
||||
label="텍스트 색상"
|
||||
ariaLabelColorInput="리스트 텍스트 색상 피커"
|
||||
ariaLabelHexInput="리스트 텍스트 색상 HEX"
|
||||
// textColorCustom 이 비어 있으면 커스텀 색상을 사용하지 않고, "없음" 상태로 취급한다.
|
||||
// 이 경우 HEX 인풋은 빈 문자열을 유지하고, 팔레트 라벨은 "없음"으로 표시된다.
|
||||
value={
|
||||
listProps.textColorCustom && listProps.textColorCustom.trim() !== ""
|
||||
? listProps.textColorCustom
|
||||
: "#e5e7eb"
|
||||
: ""
|
||||
}
|
||||
onChange={(hex) => {
|
||||
updateBlock(selectedBlockId, { textColorCustom: hex } as any);
|
||||
@@ -182,7 +184,7 @@ export function ListPropertiesPanel({
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>불릿 스타일</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="리스트 불릿 스타일"
|
||||
value={listProps.bulletStyle ?? "disc"}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -38,14 +38,14 @@ export function ProjectPropertiesPanel() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-xs text-slate-200">
|
||||
<div className="space-y-4 text-xs text-slate-900 dark:text-slate-200">
|
||||
<h3 className="text-sm font-medium text-slate-100">프로젝트 설정</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">프로젝트 제목</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"
|
||||
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="프로젝트 제목"
|
||||
value={projectConfig.title}
|
||||
onChange={(e) => updateProjectConfig({ title: e.target.value })}
|
||||
@@ -55,9 +55,9 @@ 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">프로젝트 주소 (slug)</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="프로젝트 주소 (slug)"
|
||||
value={projectConfig.slug}
|
||||
onChange={(e) => updateProjectConfig({ slug: e.target.value })}
|
||||
@@ -105,12 +105,12 @@ 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">SEO / 메타</h4>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">SEO 타이틀</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">SEO 타이틀</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="SEO 타이틀"
|
||||
placeholder={projectConfig.title || "페이지 제목"}
|
||||
value={projectConfig.seoTitle ?? ""}
|
||||
@@ -119,9 +119,9 @@ export function ProjectPropertiesPanel() {
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-slate-400">메타 디스크립션</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">메타 디스크립션</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]"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 min-h-[56px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="메타 디스크립션"
|
||||
placeholder="검색엔진 및 SNS 공유에 노출될 페이지 설명을 입력하세요."
|
||||
value={projectConfig.seoDescription ?? ""}
|
||||
@@ -130,9 +130,9 @@ export function ProjectPropertiesPanel() {
|
||||
</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">OG/Twitter 이미지 URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="OG/Twitter 이미지 URL"
|
||||
placeholder="예: https://example.com/og-image.png"
|
||||
value={projectConfig.seoOgImageUrl ?? ""}
|
||||
@@ -141,9 +141,9 @@ export function ProjectPropertiesPanel() {
|
||||
</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">Canonical URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="Canonical URL"
|
||||
placeholder="예: https://example.com/landing"
|
||||
value={projectConfig.seoCanonicalUrl ?? ""}
|
||||
@@ -151,10 +151,10 @@ export function ProjectPropertiesPanel() {
|
||||
/>
|
||||
</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"
|
||||
className="h-3 w-3 rounded border-slate-300 bg-white text-sky-600 dark:border-slate-600 dark:bg-slate-900"
|
||||
aria-label="검색 엔진에 노출하지 않기 (noindex)"
|
||||
checked={Boolean(projectConfig.seoNoIndex)}
|
||||
onChange={(e) => updateProjectConfig({ seoNoIndex: e.target.checked })}
|
||||
@@ -165,9 +165,9 @@ export function ProjectPropertiesPanel() {
|
||||
|
||||
<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">페이지 head HTML</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]"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] font-mono text-slate-900 outline-none focus:border-sky-500 min-h-[72px] dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="페이지 head HTML"
|
||||
value={projectConfig.headHtml ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ headHtml: e.target.value })}
|
||||
@@ -178,9 +178,9 @@ export function ProjectPropertiesPanel() {
|
||||
|
||||
<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">추적 스크립트</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]"
|
||||
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="추적 스크립트"
|
||||
value={projectConfig.trackingScript ?? ""}
|
||||
onChange={(e) => updateProjectConfig({ trackingScript: e.target.value })}
|
||||
|
||||
@@ -666,13 +666,13 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
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>
|
||||
</h2>
|
||||
@@ -681,7 +681,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
<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">
|
||||
@@ -691,7 +691,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
</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">
|
||||
@@ -703,7 +703,7 @@ export function PropertiesSidebar(props: PropertiesSidebarProps) {
|
||||
<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" />
|
||||
@@ -864,7 +864,7 @@ 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
|
||||
|
||||
@@ -61,7 +61,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 소스</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"
|
||||
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="배경 이미지 소스"
|
||||
value={backgroundSource}
|
||||
onChange={(e) => {
|
||||
@@ -94,7 +94,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="배경 이미지 URL"
|
||||
value={sectionProps.backgroundImageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -157,7 +157,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 위치 모드</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"
|
||||
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="배경 이미지 위치 모드"
|
||||
value={positionMode}
|
||||
onChange={(e) =>
|
||||
@@ -174,7 +174,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 크기</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"
|
||||
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="배경 이미지 크기"
|
||||
value={sectionProps.backgroundImageSize ?? "cover"}
|
||||
onChange={(e) =>
|
||||
@@ -193,7 +193,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 위치</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"
|
||||
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="배경 이미지 위치"
|
||||
value={sectionProps.backgroundImagePosition ?? "center"}
|
||||
onChange={(e) =>
|
||||
@@ -266,7 +266,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 이미지 반복</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"
|
||||
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="배경 이미지 반복"
|
||||
value={sectionProps.backgroundImageRepeat ?? "no-repeat"}
|
||||
onChange={(e) =>
|
||||
@@ -290,7 +290,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 소스</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"
|
||||
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="배경 비디오 소스"
|
||||
value={backgroundVideoSource}
|
||||
onChange={(e) => {
|
||||
@@ -323,7 +323,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>배경 비디오 URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="배경 비디오 URL"
|
||||
value={sectionProps.backgroundVideoSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -413,7 +413,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>섹션 컬럼 레이아웃</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="섹션 컬럼 레이아웃"
|
||||
value={(() => {
|
||||
const cols = sectionProps.columns ?? [];
|
||||
@@ -725,7 +725,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>컬럼 세로 정렬</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="섹션 컬럼 세로 정렬"
|
||||
value={sectionProps.alignItems ?? "top"}
|
||||
onChange={(e) => {
|
||||
|
||||
@@ -99,7 +99,7 @@ export function TextPropertiesPanel({
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-400">선택한 텍스트 블록 내용</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"
|
||||
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="선택한 텍스트 블록 내용"
|
||||
value={textProps.text}
|
||||
onChange={(e) => {
|
||||
@@ -116,7 +116,7 @@ export function TextPropertiesPanel({
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>정렬</span>
|
||||
<select
|
||||
className="rounded border border-slate-700 bg-slate-900 px-2 py-1 text-xs outline-none focus:border-sky-500"
|
||||
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="정렬"
|
||||
value={textProps.align}
|
||||
onChange={(e) => {
|
||||
@@ -140,7 +140,7 @@ export function TextPropertiesPanel({
|
||||
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, {
|
||||
@@ -155,7 +155,7 @@ export function TextPropertiesPanel({
|
||||
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, {
|
||||
@@ -170,7 +170,7 @@ export function TextPropertiesPanel({
|
||||
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, {
|
||||
@@ -246,7 +246,7 @@ export function TextPropertiesPanel({
|
||||
<span>글자 간격</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"
|
||||
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="글자 간격 프리셋"
|
||||
value={letterSpacingPreset}
|
||||
onChange={(e) => {
|
||||
@@ -457,7 +457,7 @@ export function TextPropertiesPanel({
|
||||
<span>최대 너비</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"
|
||||
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="최대 너비 프리셋"
|
||||
value={maxWidthScale}
|
||||
onChange={(e) => {
|
||||
@@ -502,7 +502,7 @@ 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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="최대 너비 커스텀"
|
||||
placeholder="예: 600px, 40rem, 80%, 60ch"
|
||||
value={textProps.maxWidthCustom ?? ""}
|
||||
|
||||
@@ -24,7 +24,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 소스</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"
|
||||
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="비디오 소스"
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
@@ -53,7 +53,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1 text-xs text-slate-400">
|
||||
<span>비디오 URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="비디오 URL"
|
||||
value={videoProps.sourceUrl ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -112,7 +112,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>포스터 이미지 URL</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"
|
||||
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||
aria-label="포스터 이미지 URL"
|
||||
value={videoProps.posterImageSrc ?? ""}
|
||||
onChange={(e) => {
|
||||
@@ -175,7 +175,7 @@ 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">비디오 스타일</h4>
|
||||
|
||||
{/* 카드 배경색 */}
|
||||
<div className="space-y-1">
|
||||
@@ -196,7 +196,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>비디오 정렬</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"
|
||||
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="비디오 정렬"
|
||||
value={videoProps.align ?? "center"}
|
||||
onChange={(e) =>
|
||||
@@ -215,7 +215,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>비디오 너비 모드</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"
|
||||
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="비디오 너비 모드"
|
||||
value={videoProps.widthMode ?? "auto"}
|
||||
onChange={(e) =>
|
||||
@@ -311,7 +311,7 @@ export function VideoPropertiesPanel({ videoProps, selectedBlockId, updateBlock
|
||||
<label className="flex flex-col gap-1">
|
||||
<span>화면 비율</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"
|
||||
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="화면 비율"
|
||||
value={videoProps.aspectRatio ?? "16:9"}
|
||||
onChange={(e) =>
|
||||
|
||||
+33
-9
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { SunMoon } from "lucide-react";
|
||||
|
||||
// 로그인 페이지 컴포넌트
|
||||
// - 이메일/비밀번호를 입력받아 /api/auth/login 으로 요청을 전송한다.
|
||||
@@ -16,6 +17,19 @@ export default function LoginPage() {
|
||||
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;
|
||||
|
||||
@@ -71,16 +85,26 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
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">로그인</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-2 py-1 text-[11px] font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
onClick={handleToggleTheme}
|
||||
>
|
||||
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
||||
<span>테마 전환</span>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400">프로젝트를 관리하려면 먼저 계정으로 로그인하세요.</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">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
@@ -88,13 +112,13 @@ export default function LoginPage() {
|
||||
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">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
@@ -102,7 +126,7 @@ export default function LoginPage() {
|
||||
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}
|
||||
/>
|
||||
@@ -117,7 +141,7 @@ export default function LoginPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[11px] text-slate-400">
|
||||
<p className="mt-4 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
아직 계정이 없다면
|
||||
{" "}
|
||||
<Link href="/signup" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@ export default function HomePage() {
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<p className="text-sm text-slate-400">대시보드로 이동 중입니다...</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+13
-10
@@ -91,10 +91,13 @@ export default function PreviewPage() {
|
||||
const canvasStyle: CSSProperties = {};
|
||||
const preset = projectConfig?.canvasPreset ?? "full";
|
||||
|
||||
const mainStyle: CSSProperties = {};
|
||||
if (projectConfig?.bodyBgColorHex && projectConfig.bodyBgColorHex.trim()) {
|
||||
mainStyle.backgroundColor = projectConfig.bodyBgColorHex;
|
||||
}
|
||||
const mainStyle: CSSProperties = {};
|
||||
{
|
||||
const raw = typeof projectConfig?.bodyBgColorHex === "string" ? projectConfig.bodyBgColorHex.trim() : "";
|
||||
if (raw) {
|
||||
mainStyle.backgroundColor = raw;
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportZip = async () => {
|
||||
try {
|
||||
@@ -158,16 +161,16 @@ 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>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">빌더로 만든 페이지를 미리 보는 화면</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();
|
||||
}}
|
||||
@@ -176,13 +179,13 @@ export default function PreviewPage() {
|
||||
</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"
|
||||
>
|
||||
프로젝트 목록
|
||||
</Link>
|
||||
<Link
|
||||
href={editorHref}
|
||||
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"
|
||||
>
|
||||
에디터로 돌아가기
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"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";
|
||||
|
||||
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
|
||||
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
|
||||
@@ -26,6 +28,7 @@ export default function ProjectSubmissionsPage() {
|
||||
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(() => {
|
||||
@@ -102,46 +105,121 @@ export default function ProjectSubmissionsPage() {
|
||||
.join("\n");
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/login");
|
||||
} catch {
|
||||
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
}
|
||||
};
|
||||
|
||||
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">폼 제출 내역</h1>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">프로젝트에 연결된 폼으로 수집된 제출 데이터를 확인할 수 있습니다.</p>
|
||||
<p className="mt-1 text-[11px] font-mono text-slate-500 dark:text-slate-400">{slug}</p>
|
||||
</div>
|
||||
<div className="text-xs text-slate-300 flex flex-col items-end gap-1">
|
||||
<span className="font-mono text-[11px]">{slug}</span>
|
||||
<a
|
||||
href="/projects"
|
||||
role="link"
|
||||
className="text-[11px] text-sky-400 hover:text-sky-300 underline-offset-2 hover:underline"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
router.push("/projects");
|
||||
}}
|
||||
<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>대시보드</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
|
||||
>
|
||||
<FolderKanban className="w-4 h-4" aria-hidden="true" />
|
||||
<span>프로젝트 목록</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/projects/submissions"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
|
||||
>
|
||||
<ListChecks className="w-4 h-4" aria-hidden="true" />
|
||||
<span>전체 제출 내역</span>
|
||||
</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}
|
||||
>
|
||||
프로젝트 목록
|
||||
</a>
|
||||
<SunMoon className="w-4 h-4" aria-hidden="true" />
|
||||
<span>테마 전환</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);
|
||||
}}
|
||||
>
|
||||
메뉴
|
||||
</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();
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</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" && 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">폼 제출 내역을 불러오는 중입니다...</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">아직 수집된 폼 제출 내역이 없습니다.</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">
|
||||
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="py-2 pr-4">제출 시각</th>
|
||||
<th className="py-2 pr-4">이름</th>
|
||||
<th className="py-2 pr-4">이메일</th>
|
||||
@@ -165,15 +243,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>
|
||||
|
||||
+23
-20
@@ -292,11 +292,11 @@ export default function ProjectsPage() {
|
||||
<>
|
||||
<div
|
||||
data-testid="projects-toolbar"
|
||||
className="flex items-center justify-between mb-2 text-[11px] text-slate-300"
|
||||
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>개
|
||||
</span>
|
||||
@@ -305,7 +305,7 @@ export default function ProjectsPage() {
|
||||
<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();
|
||||
@@ -316,7 +316,7 @@ export default function ProjectsPage() {
|
||||
</button>
|
||||
<Link
|
||||
href="/editor?new=1"
|
||||
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-2 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-sky-500 bg-sky-600 text-white hover:bg-sky-700 shadow-sm dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
|
||||
>
|
||||
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
|
||||
<span>새 프로젝트 만들기</span>
|
||||
@@ -325,7 +325,7 @@ export default function ProjectsPage() {
|
||||
</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"
|
||||
@@ -357,7 +357,10 @@ export default function ProjectsPage() {
|
||||
{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"
|
||||
@@ -375,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]">
|
||||
@@ -388,28 +391,28 @@ 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>
|
||||
</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>
|
||||
</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>
|
||||
</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);
|
||||
}}
|
||||
@@ -425,9 +428,9 @@ 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">
|
||||
<span className="px-2 py-1 rounded-full bg-slate-100 border border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
|
||||
전체 {totalCount}개 중{" "}
|
||||
<span className="font-semibold">
|
||||
{totalCount === 0 ? 0 : startIndex + 1}–{Math.min(endIndex, totalCount)}
|
||||
@@ -446,7 +449,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}
|
||||
@@ -458,7 +461,7 @@ 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" />
|
||||
@@ -467,7 +470,7 @@ export default function ProjectsPage() {
|
||||
<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>
|
||||
|
||||
@@ -197,7 +197,7 @@ export default function AllProjectSubmissionsPage() {
|
||||
{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">
|
||||
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="py-2 pr-4">제출 시각</th>
|
||||
<th className="py-2 pr-4">프로젝트</th>
|
||||
<th className="py-2 pr-4">Slug</th>
|
||||
@@ -223,17 +223,20 @@ export default function AllProjectSubmissionsPage() {
|
||||
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">{item.projectTitle ?? "-"}</td>
|
||||
<td className="py-2 pr-4 text-slate-300">{item.projectSlug}</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">{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>
|
||||
|
||||
+85
-11
@@ -4,15 +4,37 @@ import { FormEvent, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
// 회원가입 페이지 컴포넌트
|
||||
// - 이메일/비밀번호를 입력받아 /api/auth/signup 으로 요청을 전송한다.
|
||||
// - 성공 시 /projects 로 이동하고, 실패 시 에러 메시지를 보여준다.
|
||||
type PasswordStrengthLabel = "약함" | "보통" | "강함";
|
||||
|
||||
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 "약함";
|
||||
}
|
||||
|
||||
if (score === 3) {
|
||||
return "보통";
|
||||
}
|
||||
|
||||
return "강함";
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,6 +63,11 @@ export default function SignupPage() {
|
||||
e.preventDefault();
|
||||
|
||||
setError(null);
|
||||
if (password !== passwordConfirm) {
|
||||
setError("비밀번호와 비밀번호 확인이 일치하지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -69,17 +96,19 @@ export default function SignupPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const strengthLabel = getPasswordStrengthLabel(password);
|
||||
|
||||
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">
|
||||
<main className="min-h-screen flex items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
|
||||
<div className="w-full max-w-sm rounded-lg border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<h1 className="text-lg font-semibold mb-1">회원가입</h1>
|
||||
<p className="text-xs text-slate-400 mb-4">새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.</p>
|
||||
<p className="text-xs text-slate-500 mb-4 dark:text-slate-400">새 계정을 생성한 뒤 프로젝트를 저장하고 관리할 수 있습니다.</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">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
@@ -87,13 +116,13 @@ export default function SignupPage() {
|
||||
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">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
@@ -101,7 +130,52 @@ export default function SignupPage() {
|
||||
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 === "약함"
|
||||
? "w-1/3 bg-red-500"
|
||||
: strengthLabel === "보통"
|
||||
? "w-2/3 bg-amber-500"
|
||||
: "w-full bg-emerald-600")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
"text-[11px] " +
|
||||
(strengthLabel === "약함"
|
||||
? "text-red-500 dark:text-red-300"
|
||||
: strengthLabel === "보통"
|
||||
? "text-amber-500 dark:text-amber-300"
|
||||
: "text-emerald-600 dark:text-emerald-300")
|
||||
}
|
||||
>
|
||||
비밀번호 난이도: {strengthLabel}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="passwordConfirm" className="block text-slate-800 dark:text-slate-200">
|
||||
비밀번호 확인
|
||||
</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}
|
||||
/>
|
||||
@@ -116,7 +190,7 @@ export default function SignupPage() {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[11px] text-slate-400">
|
||||
<p className="mt-4 text-[11px] text-slate-500 dark:text-slate-400">
|
||||
이미 계정이 있다면
|
||||
{" "}
|
||||
<Link href="/login" className="text-sky-300 hover:text-sky-200 underline-offset-2 hover:underline">
|
||||
|
||||
@@ -32,7 +32,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" },
|
||||
@@ -101,24 +101,26 @@ 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"
|
||||
value={value}
|
||||
@@ -128,30 +130,30 @@ 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">
|
||||
<span className="truncate text-slate-700 dark:text-slate-100">
|
||||
{selectedPalette?.label ?? "색상 팔레트"}
|
||||
</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,7 +163,7 @@ 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -101,9 +101,6 @@ 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 [formStatus, setFormStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [formMessage, setFormMessage] = useState<string>("");
|
||||
|
||||
@@ -329,7 +326,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
const isRequired = requiredFieldIdSet.has(block.id);
|
||||
|
||||
const labelDisplay = (props as any).labelDisplay ?? "visible";
|
||||
const wrapperClassName = `${tokens.wrapperLayoutClass} text-xs text-slate-200`;
|
||||
const wrapperClassName = `${tokens.wrapperLayoutClass} text-xs`;
|
||||
|
||||
const rawPlaceholder =
|
||||
typeof props.placeholder === "string" ? props.placeholder.trim() : "";
|
||||
@@ -355,7 +352,9 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
? (props as any).paddingY
|
||||
: null;
|
||||
|
||||
const floatingFieldStyle: CSSProperties = {};
|
||||
// 플로팅 필드 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`;
|
||||
}
|
||||
@@ -391,7 +390,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
/>
|
||||
)}
|
||||
{helperText && (
|
||||
<p className="mt-1 text-[10px] text-slate-400">{helperText}</p>
|
||||
<p className="mt-1 text-[10px]">{helperText}</p>
|
||||
)}
|
||||
<label htmlFor={inputId} className="pb-form-label">
|
||||
{props.label}
|
||||
@@ -523,7 +522,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
|
||||
const gapEm = pxToEm(gapPx);
|
||||
|
||||
const baseClass = ["text-slate-200", tokens.widthClass].filter(Boolean).join(" ");
|
||||
const baseClass = [tokens.widthClass].filter(Boolean).join(" ");
|
||||
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
|
||||
|
||||
const groupStyle: CSSProperties = {
|
||||
@@ -563,7 +562,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
>
|
||||
<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}
|
||||
@@ -616,7 +615,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
const gapPx = typeof (props as any).labelGapPx === "number" ? (props as any).labelGapPx : 8;
|
||||
const gapEm = pxToEm(gapPx);
|
||||
|
||||
const baseClass = ["text-slate-200", tokens.widthClass].filter(Boolean).join(" ");
|
||||
const baseClass = [tokens.widthClass].filter(Boolean).join(" ");
|
||||
const layoutClass = isInlineLayout ? "flex flex-row items-center" : "flex flex-col gap-1";
|
||||
|
||||
const groupStyle: CSSProperties = {
|
||||
@@ -655,7 +654,7 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
>
|
||||
<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}
|
||||
@@ -1054,21 +1053,49 @@ export function PublicPageRenderer({ blocks, projectSlug }: PublicPageRendererPr
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col text-slate-50">
|
||||
{/* 루트 텍스트/버튼/이미지 블록들이 있다면 pb-root 내에 페이지 상단에 노출 */}
|
||||
{rootBlocks.length > 0 && (
|
||||
<section className="pb-root">
|
||||
<div className="pb-root-inner">
|
||||
{rootBlocks.map((b) => (
|
||||
<div key={b.id}>{renderBlock(b)}</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
type LayoutChunk =
|
||||
| { kind: "root"; blocks: Block[] }
|
||||
| { kind: "section"; section: Block };
|
||||
|
||||
{/* 섹션 블록들 */}
|
||||
{sectionBlocks.map((section) => renderSection(section))}
|
||||
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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
return renderSection(chunk.section);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -623,7 +623,10 @@ export const computeFormInputPublicTokens = (props: FormInputBlockProps): FormIn
|
||||
}
|
||||
|
||||
if (props.strokeColorCustom && props.strokeColorCustom.trim() !== "") {
|
||||
inputStyle.borderColor = props.strokeColorCustom.trim();
|
||||
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) {
|
||||
@@ -814,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) {
|
||||
|
||||
+19
-13
@@ -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;
|
||||
@@ -150,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);
|
||||
@@ -433,6 +436,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
/* 플로팅 라벨 패치가 참조하는 입력 테두리 색 기본값 */
|
||||
--pb-input-border-color: var(--pb-color-input-border);
|
||||
}
|
||||
|
||||
.pb-form-field--floating {
|
||||
@@ -449,10 +454,9 @@ body {
|
||||
top: calc(var(--pb-input-padding-y) + 0.3rem);
|
||||
left: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
transform-origin: left top;
|
||||
transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease;
|
||||
transition: top 0.3s ease, font-size 0.3s ease, color 0.3s ease, background 0.3s ease;
|
||||
}
|
||||
|
||||
.pb-form-field--floating .pb-input,
|
||||
@@ -470,16 +474,22 @@ body {
|
||||
.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.3rem; /* 인풋 상단 보더에 살짝 겹치는 정도로만 띄운다. */
|
||||
top: -0.425rem; /* 인풋 상단 보더에 살짝 겹치는 정도로만 띄운다. */
|
||||
font-size: 0.725rem;
|
||||
color: #e5e7eb;
|
||||
background-color: #020617; /* 인풋 배경과 맞춰서 보더 라인을 자연스럽게 가린다. */
|
||||
padding: 0 0.25rem;
|
||||
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 {
|
||||
@@ -501,20 +511,17 @@ 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;
|
||||
/* 세로 패딩은 CSS 변수로 정의해 플로팅 라벨/에디터 등이 함께 참조할 수 있게 한다. */
|
||||
padding: var(--pb-input-padding-y, 0.5rem) 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -534,7 +541,6 @@ body {
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: var(--pb-leading-normal);
|
||||
color: var(--pb-color-text-default);
|
||||
}
|
||||
|
||||
/* 리스트 아이템 간 여백은 --pb-list-gap 커스텀 프로퍼티로 제어한다.
|
||||
|
||||
@@ -8,3 +8,58 @@
|
||||
/* 초기 상태에서는 별도 규칙 없이 파일만 생성해 두고,
|
||||
* 이후 에디터 전용 레이아웃/패널 스타일을 점진적으로 이동시킨다.
|
||||
*/
|
||||
|
||||
/*
|
||||
* 에디터/프리뷰용 스크롤바 테마
|
||||
* - .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,
|
||||
|
||||
Reference in New Issue
Block a user