오류 수정
CI / test (push) Failing after 5m41s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped

This commit is contained in:
2025-12-12 18:04:31 +09:00
parent 6804665b95
commit 4840a530b6
205 changed files with 1802 additions and 9887 deletions
@@ -99,10 +99,10 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={80}
step={2}
presets={[
{ id: "tight", label: "타이트", value: 4 },
{ id: "normal", label: "보통", value: 8 },
{ id: "relaxed", label: "느슨", value: 12 },
{ id: "extra", label: "넓게", value: 16 },
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -235,7 +235,7 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
? [...(checkboxProps as any).options]
: [];
next.push({
label: "새 옵션",
label: "New option",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
@@ -442,9 +442,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -466,9 +466,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -501,9 +501,9 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={800}
step={10}
presets={[
{ id: "sm", label: "작게", value: 200 },
{ id: "md", label: "보통", value: 280 },
{ id: "lg", label: "넓게", value: 360 },
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -520,11 +520,11 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={60}
step={2}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 10 },
{ id: "md", label: "보통", value: 12 },
{ id: "lg", label: "넓게", value: 16 },
{ id: "xl", label: "아주 넓게", value: 20 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 10 },
{ id: "md", label: "Normal", value: 12 },
{ id: "lg", label: "Wide", value: 16 },
{ id: "xl", label: "Extra wide", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -540,11 +540,11 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={40}
step={1}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 8 },
{ id: "md", label: "보통", value: 10 },
{ id: "lg", label: "넓게", value: 12 },
{ id: "xl", label: "아주 넓게", value: 16 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 8 },
{ id: "md", label: "Normal", value: 10 },
{ id: "lg", label: "Wide", value: 12 },
{ id: "xl", label: "Extra wide", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -560,10 +560,10 @@ export function FormCheckboxPropertiesPanel({ block, selectedBlockId, updateBloc
max={40}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 2 },
{ id: "normal", label: "보통", value: 4 },
{ id: "relaxed", label: "느슨", value: 8 },
{ id: "extra", label: "넓게", value: 12 },
{ id: "tight", label: "Tight", value: 2 },
{ id: "normal", label: "Normal", value: 4 },
{ id: "relaxed", label: "Relaxed", value: 8 },
{ id: "extra", label: "Extra", value: 12 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
+7 -5
View File
@@ -216,6 +216,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
<option value="fixed">{m.formWidthModeOptionFixed}</option>
</select>
</label>
{(formProps.formWidthMode ?? "auto") === "fixed" && (
<NumericPropertyControl
label={m.formFixedWidthLabel}
@@ -225,9 +226,9 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
max={1200}
step={10}
presets={[
{ id: "sm", label: "좁게", value: 320 },
{ id: "md", label: "보통", value: 480 },
{ id: "lg", label: "넓게", value: 640 },
{ id: "sm", label: m.formFixedWidthPresetNarrow, value: 320 },
{ id: "md", label: m.formFixedWidthPresetNormal, value: 480 },
{ id: "lg", label: m.formFixedWidthPresetWide, value: 640 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -236,6 +237,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
}
/>
)}
<NumericPropertyControl
label={m.marginYLabel}
unitLabel="(px)"
@@ -352,7 +354,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any);
}}
/>
<span></span>
<span>{m.requiredLabel}</span>
</label>
</label>
);
@@ -371,7 +373,7 @@ export function FormControllerPanel({ block, blocks, selectedBlockId, updateBloc
} as any)
}
>
<option value=""> </option>
<option value="">{m.submitButtonNoneOptionLabel}</option>
{blocks
.filter((b) => b.type === "button")
.map((buttonBlock) => {
@@ -92,10 +92,10 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={80}
step={2}
presets={[
{ id: "tight", label: "타이트", value: 4 },
{ id: "normal", label: "보통", value: 8 },
{ id: "relaxed", label: "느슨", value: 12 },
{ id: "extra", label: "넓게", value: 16 },
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -218,9 +218,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -242,9 +242,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -293,9 +293,9 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={800}
step={10}
presets={[
{ id: "sm", label: "작게", value: 200 },
{ id: "md", label: "보통", value: 280 },
{ id: "lg", label: "넓게", value: 360 },
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -311,11 +311,11 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={60}
step={2}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 10 },
{ id: "md", label: "보통", value: 12 },
{ id: "lg", label: "넓게", value: 16 },
{ id: "xl", label: "아주 넓게", value: 20 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 10 },
{ id: "md", label: "Normal", value: 12 },
{ id: "lg", label: "Wide", value: 16 },
{ id: "xl", label: "Extra wide", value: 20 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -331,11 +331,11 @@ export function FormInputPropertiesPanel({ block, selectedBlockId, updateBlock }
max={40}
step={1}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 8 },
{ id: "md", label: "보통", value: 10 },
{ id: "lg", label: "넓게", value: 12 },
{ id: "xl", label: "아주 넓게", value: 16 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 8 },
{ id: "md", label: "Normal", value: 10 },
{ id: "lg", label: "Wide", value: 12 },
{ id: "xl", label: "Extra wide", value: 16 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -25,7 +25,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
<h3 className="text-[11px] font-semibold text-slate-800 dark:text-slate-200">{m.sectionTitle}</h3>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleTypeLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabelMode ?? "text"}
@@ -35,13 +35,13 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="text"></option>
<option value="image"></option>
<option value="text">{m.groupTitleTypeOptionText}</option>
<option value="image">{m.groupTitleTypeOptionImage}</option>
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleDisplayLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={groupLabelDisplay}
@@ -51,14 +51,14 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="visible"> ()</option>
<option value="hidden"></option>
<option value="visible">{m.groupTitleDisplayOptionVisible}</option>
<option value="hidden">{m.groupTitleDisplayOptionHidden}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.layoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.labelLayout ?? "stacked"}
@@ -68,14 +68,14 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
<option value="stacked">{m.layoutOptionStacked}</option>
<option value="inline">{m.layoutOptionInline}</option>
</select>
</label>
)}
<label className="flex flex-col gap-1 text-[11px] text-slate-500 dark:text-slate-400">
<span> </span>
<span>{m.optionLayoutLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.optionLayout ?? "stacked"}
@@ -85,24 +85,24 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="stacked"> ()</option>
<option value="inline"> ()</option>
<option value="stacked">{m.optionLayoutOptionStacked}</option>
<option value="inline">{m.optionLayoutOptionInline}</option>
</select>
</label>
{groupLabelDisplay === "visible" && (radioProps.labelLayout ?? "stacked") === "inline" && (
<NumericPropertyControl
label="라벨/필드 간격 (px)"
label={m.labelGapLabel}
unitLabel="(px)"
value={typeof radioProps.labelGapPx === "number" ? radioProps.labelGapPx : 8}
min={0}
max={80}
step={2}
presets={[
{ id: "tight", label: "타이트", value: 4 },
{ id: "normal", label: "보통", value: 8 },
{ id: "relaxed", label: "느슨", value: 12 },
{ id: "extra", label: "넓게", value: 16 },
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -113,7 +113,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
)}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.groupTitleLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.groupLabel ?? ""}
@@ -135,7 +135,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
return (
<div className="space-y-1">
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageSourceLabel}</span>
<select
className="w-full rounded border border-slate-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}
@@ -145,14 +145,14 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.groupTitleImageSourceOptionUrl}</option>
<option value="upload">{m.groupTitleImageSourceOptionUpload}</option>
</select>
</label>
{source === "url" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> URL</span>
<span className="text-slate-400">{m.groupTitleImageUrlLabel}</span>
<input
className="w-full rounded border border-slate-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 ?? ""}
@@ -167,12 +167,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-400"> </span>
<span className="text-slate-400">{m.groupTitleImageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="그룹 타이틀 이미지 파일 업로드"
aria-label={m.groupTitleImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -212,7 +212,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
})()}
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.submitKeyLabel}</span>
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-xs text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={radioProps.formFieldName ?? ""}
@@ -226,7 +226,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-slate-500 dark:text-slate-400"></span>
<span className="text-slate-500 dark:text-slate-400">{m.optionsLabel}</span>
<button
type="button"
className="rounded border border-slate-300 bg-white px-2 py-0.5 text-[10px] text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-red-900/60 dark:hover:border-red-700"
@@ -235,7 +235,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
? [...(radioProps as any).options]
: [];
next.push({
label: "새 옵션",
label: "New option",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
@@ -243,12 +243,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any);
}}
>
{m.addOptionButtonLabel}
</button>
</div>
<div className="space-y-2">
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageSourceLabel}</span>
<select
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
value={(() => {
@@ -263,8 +263,8 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
} as any)
}
>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="url">{m.optionImageSourceOptionUrl}</option>
<option value="upload">{m.optionImageSourceOptionUpload}</option>
</select>
</label>
@@ -273,7 +273,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] items-center gap-1">
<input
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="라벨"
placeholder={m.optionLabelPlaceholder}
value={opt.label ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
@@ -288,7 +288,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
/>
<input
className="flex-1 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="값(value)"
placeholder={m.optionValuePlaceholder}
value={opt.value ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
@@ -325,7 +325,7 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
{source === "url" && (
<input
className="w-full rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-900 outline-none focus:border-sky-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
placeholder="옵션 이미지 URL (선택)"
placeholder={m.optionImageUrlPlaceholder}
value={opt.labelImageUrl ?? ""}
onChange={(e) => {
const next = [...(((radioProps as any).options ?? []) as any[])];
@@ -341,12 +341,12 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
)}
{source === "upload" && (
<label className="flex flex-col gap-1">
<span className="text-slate-500 dark:text-slate-400"> </span>
<span className="text-slate-500 dark:text-slate-400">{m.optionImageUploadLabel}</span>
<input
type="file"
accept="image/*"
className="w-full text-[11px] text-slate-300 file:mr-2 file:rounded file:border file:border-slate-700 file:bg-slate-800 file:px-2 file:py-1 file:text-[11px] file:text-slate-100 hover:file:bg-slate-700"
aria-label="옵션 이미지 파일 업로드"
aria-label={m.optionImageUploadAria}
onChange={async (event) => {
const file = event.target.files?.[0];
if (!file) return;
@@ -442,9 +442,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -466,9 +466,9 @@ export function FormRadioPropertiesPanel({ block, selectedBlockId, updateBlock }
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) => {
updateBlock(selectedBlockId, {
@@ -83,10 +83,10 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={80}
step={2}
presets={[
{ id: "tight", label: "타이트", value: 4 },
{ id: "normal", label: "보통", value: 8 },
{ id: "relaxed", label: "느슨", value: 12 },
{ id: "extra", label: "넓게", value: 16 },
{ id: "tight", label: "Tight", value: 4 },
{ id: "normal", label: "Normal", value: 8 },
{ id: "relaxed", label: "Relaxed", value: 12 },
{ id: "extra", label: "Extra", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -133,7 +133,7 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
? [...(selectProps as any).options]
: [];
next.push({
label: "새 옵션",
label: "New option",
value: `option_${next.length + 1}`,
});
updateBlock(selectedBlockId, {
@@ -240,9 +240,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={60}
step={1}
presets={[
{ id: "tight", label: "타이트", value: 18 },
{ id: "normal", label: "보통", value: 24 },
{ id: "loose", label: "루즈", value: 32 },
{ id: "tight", label: "Tight", value: 18 },
{ id: "normal", label: "Normal", value: 24 },
{ id: "loose", label: "Loose", value: 32 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -264,9 +264,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={20}
step={0.5}
presets={[
{ id: "tight", label: "좁게", value: -1 },
{ id: "normal", label: "보통", value: 0 },
{ id: "wide", label: "넓게", value: 2 },
{ id: "tight", label: "Tight", value: -1 },
{ id: "normal", label: "Normal", value: 0 },
{ id: "wide", label: "Wide", value: 2 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -285,9 +285,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
} as any)
}
>
<option value="auto"></option>
<option value="full"></option>
<option value="fixed"></option>
<option value="auto">Auto</option>
<option value="full">Full</option>
<option value="fixed">Fixed</option>
</select>
</label>
{(selectProps.widthMode ?? "full") === "fixed" && (
@@ -299,9 +299,9 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={800}
step={10}
presets={[
{ id: "sm", label: "작게", value: 200 },
{ id: "md", label: "보통", value: 280 },
{ id: "lg", label: "넓게", value: 360 },
{ id: "sm", label: "Small", value: 200 },
{ id: "md", label: "Medium", value: 280 },
{ id: "lg", label: "Large", value: 360 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -318,11 +318,11 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={60}
step={2}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 10 },
{ id: "md", label: "보통", value: 12 },
{ id: "lg", label: "넓게", value: 16 },
{ id: "xl", label: "아주 넓게", value: 20 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 10 },
{ id: "md", label: "Normal", value: 12 },
{ id: "lg", label: "Wide", value: 16 },
{ id: "xl", label: "Extra wide", value: 20 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
@@ -338,11 +338,11 @@ export function FormSelectPropertiesPanel({ block, selectedBlockId, updateBlock
max={40}
step={1}
presets={[
{ id: "xs", label: "아주 얇게", value: 6 },
{ id: "sm", label: "얇게", value: 8 },
{ id: "md", label: "보통", value: 10 },
{ id: "lg", label: "넓게", value: 12 },
{ id: "xl", label: "아주 넓게", value: 16 },
{ id: "xs", label: "Extra thin", value: 6 },
{ id: "sm", label: "Thin", value: 8 },
{ id: "md", label: "Normal", value: 10 },
{ id: "lg", label: "Wide", value: 12 },
{ id: "xl", label: "Extra wide", value: 16 },
]}
onChangeValue={(v) =>
updateBlock(selectedBlockId, {
+13 -8
View File
@@ -206,7 +206,7 @@ function EditorPageInner() {
}
updateProjectConfig({
title: "새 페이지",
title: "New page",
slug: "my-landing",
canvasPreset: "full",
canvasBgColorHex: "#020617",
@@ -1084,6 +1084,7 @@ function EditorPageInner() {
listItemToolbarMoveDownLabel={m.listItemToolbarMoveDownLabel}
listItemToolbarIndentLabel={m.listItemToolbarIndentLabel}
listItemToolbarOutdentLabel={m.listItemToolbarOutdentLabel}
imageBlockEmptyPreviewPlaceholder={m.imageBlockEmptyPreviewPlaceholder}
/>
</Fragment>
);
@@ -1425,6 +1426,7 @@ interface SortableEditorBlockProps {
listItemToolbarMoveDownLabel: string;
listItemToolbarIndentLabel: string;
listItemToolbarOutdentLabel: string;
imageBlockEmptyPreviewPlaceholder: string;
}
function SortableEditorBlock({
@@ -1449,6 +1451,7 @@ function SortableEditorBlock({
listItemToolbarMoveDownLabel,
listItemToolbarIndentLabel,
listItemToolbarOutdentLabel,
imageBlockEmptyPreviewPlaceholder,
}: SortableEditorBlockProps) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: block.id,
@@ -1560,7 +1563,7 @@ function SortableEditorBlock({
<button
type="button"
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="블록 드래그 핸들"
aria-label="Block drag handle"
{...listeners}
{...attributes}
>
@@ -1571,7 +1574,7 @@ function SortableEditorBlock({
isEditing ? (
<textarea
className="w-full bg-transparent outline-none resize-none text-sm"
aria-label="텍스트 블록 내용 편집"
aria-label="Edit text block content"
value={editingText}
onChange={(e) => setEditingText(e.target.value)}
onBlur={commitEditing}
@@ -1715,8 +1718,8 @@ function SortableEditorBlock({
const options = Array.isArray(selectProps.options) && selectProps.options.length > 0
? selectProps.options
: [
{ label: "옵션 1", value: "option_1" },
{ label: "옵션 2", value: "option_2" },
{ label: "Option 1", value: "option_1" },
{ label: "Option 2", value: "option_2" },
];
const selectTokens = computeFormSelectEditorTokens(selectProps);
@@ -2080,7 +2083,7 @@ function SortableEditorBlock({
/>
) : (
<span className="text-[10px] text-slate-900 dark:text-slate-500 px-2 py-4">
URL .
{imageBlockEmptyPreviewPlaceholder}
</span>
)}
</div>
@@ -2147,7 +2150,7 @@ function SortableEditorBlock({
: [
{
id: `${block.id}_item_1`,
text: "리스트 아이템 1",
text: "List item 1",
children: [],
},
];
@@ -2350,6 +2353,8 @@ interface ColumnDroppableProps {
function ColumnDroppable({ id, sectionId, columnId, basis, blocks, renderBlocks, span }: ColumnDroppableProps) {
const { setNodeRef } = useDroppable({ id });
const { over } = useDndContext();
const locale = useAppLocale();
const editorMessages = getEditorMessages(locale);
// 현재 드롭 대상이 이 컬럼이거나, 이 컬럼 안 블록들의 dropzone-before / dropzone-after 인 경우
// 컬럼 박스를 강조해서 사용자가 어느 컬럼으로 이동 중인지 명확히 보여준다.
@@ -2400,7 +2405,7 @@ function ColumnDroppable({ id, sectionId, columnId, basis, blocks, renderBlocks,
>
{blocks.length === 0 ? (
<span className="text-[11px] px-2 text-center text-slate-900 dark:text-slate-300">
{`컬럼 영역 (span ${span}/12)`}
{`${editorMessages.columnAreaLabel} (span ${span}/12)`}
</span>
) : (
<SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
+37 -22
View File
@@ -50,43 +50,58 @@ export function BlocksSidebar() {
const addFooterTemplateSection = useEditorStore((state) => state.addFooterTemplateSection);
// 버튼 핸들러를 useCallback으로 래핑해 불필요한 재생성을 줄인다.
const handleAddText = useCallback(() => addTextBlock(), [addTextBlock]);
const handleAddButton = useCallback(() => addButtonBlock(), [addButtonBlock]);
const handleAddText = useCallback(() => addTextBlock(locale), [addTextBlock, locale]);
const handleAddButton = useCallback(() => addButtonBlock(locale), [addButtonBlock, locale]);
const handleAddImage = useCallback(() => addImageBlock(), [addImageBlock]);
const handleAddVideo = useCallback(() => addVideoBlock(), [addVideoBlock]);
const handleAddDivider = useCallback(() => addDividerBlock(), [addDividerBlock]);
const handleAddList = useCallback(() => addListBlock(), [addListBlock]);
const handleAddList = useCallback(() => addListBlock(locale), [addListBlock, locale]);
const handleAddSection = useCallback(() => addSectionBlock(), [addSectionBlock]);
const handleAddFormBlock = useCallback(() => addFormBlock(), [addFormBlock]);
const handleAddFormInput = useCallback(() => addFormInputBlock(), [addFormInputBlock]);
const handleAddFormSelect = useCallback(() => addFormSelectBlock(), [addFormSelectBlock]);
const handleAddFormRadio = useCallback(() => addFormRadioBlock(), [addFormRadioBlock]);
const handleAddFormCheckbox = useCallback(() => addFormCheckboxBlock(), [addFormCheckboxBlock]);
const handleAddFormBlock = useCallback(() => addFormBlock(locale), [addFormBlock, locale]);
const handleAddFormInput = useCallback(() => addFormInputBlock(locale), [addFormInputBlock, locale]);
const handleAddFormSelect = useCallback(() => addFormSelectBlock(locale), [addFormSelectBlock, locale]);
const handleAddFormRadio = useCallback(() => addFormRadioBlock(locale), [addFormRadioBlock, locale]);
const handleAddFormCheckbox = useCallback(
() => addFormCheckboxBlock(locale),
[addFormCheckboxBlock, locale],
);
const handleAddHeroTemplate = useCallback(
() => addHeroTemplateSection(),
[addHeroTemplateSection],
() => addHeroTemplateSection(locale),
[addHeroTemplateSection, locale],
);
const handleAddFeaturesTemplate = useCallback(
() => addFeaturesTemplateSection(),
[addFeaturesTemplateSection],
() => addFeaturesTemplateSection(locale),
[addFeaturesTemplateSection, locale],
);
const handleAddCtaTemplate = useCallback(
() => addCtaTemplateSection(locale),
[addCtaTemplateSection, locale],
);
const handleAddFaqTemplate = useCallback(
() => addFaqTemplateSection(locale),
[addFaqTemplateSection, locale],
);
const handleAddCtaTemplate = useCallback(() => addCtaTemplateSection(), [addCtaTemplateSection]);
const handleAddFaqTemplate = useCallback(() => addFaqTemplateSection(), [addFaqTemplateSection]);
const handleAddPricingTemplate = useCallback(
() => addPricingTemplateSection(),
[addPricingTemplateSection],
() => addPricingTemplateSection(locale),
[addPricingTemplateSection, locale],
);
const handleAddTestimonialsTemplate = useCallback(
() => addTestimonialsTemplateSection(),
[addTestimonialsTemplateSection],
() => addTestimonialsTemplateSection(locale),
[addTestimonialsTemplateSection, locale],
);
const handleAddBlogTemplate = useCallback(
() => addBlogTemplateSection(locale),
[addBlogTemplateSection, locale],
);
const handleAddTeamTemplate = useCallback(
() => addTeamTemplateSection(locale),
[addTeamTemplateSection, locale],
);
const handleAddBlogTemplate = useCallback(() => addBlogTemplateSection(), [addBlogTemplateSection]);
const handleAddTeamTemplate = useCallback(() => addTeamTemplateSection(), [addTeamTemplateSection]);
const handleAddFooterTemplate = useCallback(
() => addFooterTemplateSection(),
[addFooterTemplateSection],
() => addFooterTemplateSection(locale),
[addFooterTemplateSection, locale],
);
const [isBlocksOpen, setIsBlocksOpen] = useState(true);
@@ -88,9 +88,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
}
}}
>
<option value="none">{m.backgroundVideoSourceOptionNone}</option>
<option value="url">{m.backgroundVideoSourceOptionUrl}</option>
<option value="upload">{m.backgroundVideoSourceOptionUpload}</option>
<option value="none">{m.backgroundImageSourceOptionNone}</option>
<option value="url">{m.backgroundImageSourceOptionUrl}</option>
<option value="upload">{m.backgroundImageSourceOptionUpload}</option>
</select>
</label>
@@ -229,9 +229,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
max={100}
step={1}
presets={[
{ id: "left", label: "왼쪽", value: 0 },
{ id: "center", label: "가운데", value: 50 },
{ id: "right", label: "오른쪽", value: 100 },
{ id: "left", label: m.backgroundImagePositionOptionLeft, value: 0 },
{ id: "center", label: m.backgroundImagePositionOptionCenter, value: 50 },
{ id: "right", label: m.backgroundImagePositionOptionRight, value: 100 },
]}
onChangeValue={(next) =>
updateBlock(selectedBlockId, {
@@ -253,9 +253,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
max={100}
step={1}
presets={[
{ id: "top", label: "위", value: 0 },
{ id: "center", label: "가운데", value: 50 },
{ id: "bottom", label: "아래", value: 100 },
{ id: "top", label: m.backgroundImagePositionOptionTop, value: 0 },
{ id: "center", label: m.backgroundImagePositionOptionCenter, value: 50 },
{ id: "bottom", label: m.backgroundImagePositionOptionBottom, value: 100 },
]}
onChangeValue={(next) =>
updateBlock(selectedBlockId, {
@@ -317,9 +317,9 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
}
}}
>
<option value="none"></option>
<option value="url">URL</option>
<option value="upload"> </option>
<option value="none">{m.backgroundVideoSourceOptionNone}</option>
<option value="url">{m.backgroundVideoSourceOptionUrl}</option>
<option value="upload">{m.backgroundVideoSourceOptionUpload}</option>
</select>
</label>
@@ -387,7 +387,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
)}
</div>
{/* 세로 패딩 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
{/* 세로 패딩 슬라이더 - 프리셋 + 자유 슬라이더 */}
<div className="mt-3 space-y-1">
<NumericPropertyControl
label={m.paddingYLabel}
@@ -397,11 +397,11 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
max={80}
step={2}
presets={[
{ id: "x-tight", label: "매우 좁게", value: 16 },
{ id: "tight", label: "좁게", value: 32 },
{ id: "normal", label: "보통", value: 48 },
{ id: "relaxed", label: "넓게", value: 64 },
{ id: "x-relaxed", label: "아주 넓게", value: 80 },
{ id: "x-tight", label: m.paddingYPresetExtraTight, value: 16 },
{ id: "tight", label: m.paddingYPresetTight, value: 32 },
{ id: "normal", label: m.paddingYPresetNormal, value: 48 },
{ id: "relaxed", label: m.paddingYPresetRelaxed, value: 64 },
{ id: "x-relaxed", label: m.paddingYPresetExtraRelaxed, value: 80 },
]}
onChangeValue={(next) => {
const safe = Number.isFinite(next) ? next : 48;
@@ -682,7 +682,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
});
})()}
{/* 최대 폭 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
{/* 최대 폭 슬라이더 - 프리셋 + 자유 슬라이더 */}
<NumericPropertyControl
label={m.maxWidthLabel}
unitLabel={m.maxWidthUnitLabel}
@@ -691,11 +691,11 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
max={1440}
step={16}
presets={[
{ id: "x-narrow", label: "매우 좁게", value: 640 },
{ id: "narrow", label: "좁게", value: 800 },
{ id: "normal", label: "보통", value: 960 },
{ id: "wide", label: "넓게", value: 1200 },
{ id: "x-wide", label: "아주 넓게", value: 1440 },
{ id: "x-narrow", label: m.maxWidthPresetExtraNarrow, value: 640 },
{ id: "narrow", label: m.maxWidthPresetNarrow, value: 800 },
{ id: "normal", label: m.maxWidthPresetNormal, value: 960 },
{ id: "wide", label: m.maxWidthPresetWide, value: 1200 },
{ id: "x-wide", label: m.maxWidthPresetExtraWide, value: 1440 },
]}
onChangeValue={(next) => {
const safe = Number.isFinite(next) ? next : 960;
@@ -703,7 +703,7 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
}}
/>
{/* 컬럼 간 간격 슬라이더 (px) - 프리셋 + 자유 슬라이더 */}
{/* 컬럼 간 간격 슬라이더 - 프리셋 + 자유 슬라이더 */}
<NumericPropertyControl
label={m.gapXLabel}
unitLabel={m.gapXUnitLabel}
@@ -712,12 +712,12 @@ export function SectionPropertiesPanel({ sectionProps, selectedBlockId, updateBl
max={64}
step={2}
presets={[
{ id: "zero", label: "0", value: 0 },
{ id: "tight", label: "좁게", value: 16 },
{ id: "normal", label: "보통", value: 24 },
{ id: "relaxed", label: "넓게", value: 32 },
{ id: "x-relaxed", label: "아주 넓게", value: 48 },
{ id: "max", label: "최대", value: 64 },
{ id: "zero", label: m.gapXPresetZero, value: 0 },
{ id: "tight", label: m.gapXPresetTight, value: 16 },
{ id: "normal", label: m.gapXPresetNormal, value: 24 },
{ id: "relaxed", label: m.gapXPresetRelaxed, value: 32 },
{ id: "x-relaxed", label: m.gapXPresetExtraRelaxed, value: 48 },
{ id: "max", label: m.gapXPresetMax, value: 64 },
]}
onChangeValue={(next) => {
const safe = Number.isFinite(next) ? next : 24;
+6 -8
View File
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps, ButtonBlockProps } from "@/features/editor/state/editorStore";
import type { EditorPricingTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createPricingTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorPricingTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,11 +35,7 @@ export function createPricingTemplateBlocks(opts: {
columnId: null,
};
const planDefinitions: Array<{ name: string; price: string; popular?: boolean }> = [
{ name: "Basic", price: "₩9,900/월" },
{ name: "Pro", price: "₩29,900/월", popular: true },
{ name: "Enterprise", price: "문의" },
];
const planDefinitions = messages.plans;
const pricingBlocks: Block[] = columns.flatMap((col, index) => {
const plan = planDefinitions[index] ?? planDefinitions[0];
@@ -96,7 +94,7 @@ export function createPricingTemplateBlocks(opts: {
// Features list (simple text for now)
const featuresId = createId();
const featuresProps: TextBlockProps = {
text: "• 모든 기본 기능\n• 이메일 지원\n• 1GB 스토리지",
text: messages.featuresText,
align: "center",
size: "sm",
color: "muted",
@@ -112,7 +110,7 @@ export function createPricingTemplateBlocks(opts: {
// Button
const buttonId = createId();
const buttonProps: ButtonBlockProps = {
label: isPopular ? "시작하기" : "선택하기",
label: isPopular ? messages.primaryButtonLabel : messages.secondaryButtonLabel,
href: "#",
align: "center",
size: "md",
@@ -1,12 +1,14 @@
"use client";
import type { Block, SectionBlockProps, TextBlockProps } from "@/features/editor/state/editorStore";
import type { EditorTestimonialsTemplateMessages } from "@/features/i18n/messages/editorTemplates";
export function createTestimonialsTemplateBlocks(opts: {
sectionId: string;
createId: () => string;
messages: EditorTestimonialsTemplateMessages;
}): { blocks: Block[]; lastSelectedId: string } {
const { sectionId, createId } = opts;
const { sectionId, createId, messages } = opts;
const columns: SectionBlockProps["columns"] = [
{ id: `${sectionId}_col_1`, span: 4 },
@@ -33,11 +35,7 @@ export function createTestimonialsTemplateBlocks(opts: {
columnId: null,
};
const testimonialDefinitions: Array<{ body: string; author: string }> = [
{ body: "이 서비스 덕분에 랜딩 페이지 제작 시간이 크게 줄었습니다.", author: "홍길동" },
{ body: "디자인을 잘 못해도 깔끔한 페이지를 만들 수 있어요.", author: "김영희" },
{ body: "팀 전체가 만족하는 빌더입니다.", author: "이철수" },
];
const testimonialDefinitions = messages.items;
const testimonialBlocks: Block[] = columns.flatMap((col, index) => {
const t = testimonialDefinitions[index] ?? testimonialDefinitions[0];
+7 -2
View File
@@ -1,7 +1,7 @@
import "../styles/globals.css";
import "../styles/builder.css";
import type { ReactNode } from "react";
import { headers } from "next/headers";
import { headers, cookies } from "next/headers";
import { LocaleProvider } from "@/features/i18n/LocaleProvider";
import { LocaleSwitcher } from "@/features/i18n/LocaleSwitcher";
import { resolveLocaleFromAcceptLanguage, DEFAULT_LOCALE } from "@/features/i18n/locale";
@@ -12,9 +12,14 @@ export const metadata = {
};
export default async function RootLayout({ children }: { children: ReactNode }) {
const cookieStore = await cookies();
const cookieLocale = cookieStore.get("pb-locale")?.value;
const headerList = await headers();
const acceptLanguage = headerList.get("accept-language");
const locale = resolveLocaleFromAcceptLanguage(acceptLanguage ?? undefined) ?? DEFAULT_LOCALE;
const localeFromCookie = cookieLocale === "en" || cookieLocale === "ko" ? cookieLocale : null;
const locale = (localeFromCookie ?? resolveLocaleFromAcceptLanguage(acceptLanguage ?? undefined)) ?? DEFAULT_LOCALE;
return (
<html lang={locale} suppressHydrationWarning>
+5 -6
View File
@@ -1,12 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { getPublicPageRendererMessages } from "@/features/i18n/messages/publicPageRenderer";
interface PublicProjectPageClientProps {
html: string;
}
export default function PublicProjectPageClient({ html }: PublicProjectPageClientProps) {
const m = getPublicPageRendererMessages(null);
const [toastMessage, setToastMessage] = useState<string | null>(null);
useEffect(() => {
@@ -58,21 +60,18 @@ export default function PublicProjectPageClient({ html }: PublicProjectPageClien
const ok = data && data.ok;
const message = data && data.message;
if (ok) {
const success =
message || (config && config.successMessage) || "성공적으로 전송되었습니다.";
const success = message || (config && config.successMessage) || m.submitSuccessDefault;
showToast(success);
try {
form.reset();
} catch {}
} else {
const errorMsg =
message || (config && config.errorMessage) || "전송 중 오류가 발생했습니다.";
const errorMsg = message || (config && config.errorMessage) || m.submitErrorDefault;
showToast(errorMsg);
}
})
.catch(() => {
const errorMsg =
(config && config.errorMessage) || "전송 중 오류가 발생했습니다.";
const errorMsg = (config && config.errorMessage) || m.submitErrorDefault;
showToast(errorMsg);
});
});
+29 -24
View File
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter, useParams } from "next/navigation";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
import { useAppLocale } from "@/features/i18n/LocaleProvider";
import { getDashboardMessages } from "@/features/i18n/messages/dashboard";
import { getSubmissionsMessages } from "@/features/i18n/messages/submissions";
// 프로젝트별 폼 제출 내역을 조회해서 보여주는 페이지 컴포넌트.
// - URL 의 slug 파라미터를 기준으로 /api/projects/[slug]/submissions 에 요청을 보낸다.
@@ -25,6 +28,10 @@ export default function ProjectSubmissionsPage() {
const params = useParams() as { slug?: string } | null;
const slug = (params?.slug ?? "").toString();
const locale = useAppLocale();
const t = getDashboardMessages(locale);
const m = getSubmissionsMessages(locale);
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
@@ -48,21 +55,19 @@ export default function ProjectSubmissionsPage() {
if (res.status === 401) {
setStatus("error");
setErrorMessage("폼 제출 내역을 조회하려면 로그인이 필요합니다. 다시 로그인해 주세요.");
setErrorMessage(m.errorUnauthorized);
router.push("/login");
return;
}
if (res.status === 404) {
setStatus("error");
setErrorMessage(
"프로젝트를 찾을 수 없거나 접근 권한이 없습니다. 프로젝트 소유자 계정으로 다시 로그인해 주세요.",
);
setErrorMessage(m.errorGeneric);
return;
}
setStatus("error");
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorGeneric);
return;
}
@@ -76,7 +81,7 @@ export default function ProjectSubmissionsPage() {
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage("폼 제출 내역을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorGeneric);
}
}
};
@@ -112,13 +117,13 @@ export default function ProjectSubmissionsPage() {
});
if (!res.ok) {
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorLogout);
return;
}
router.push("/login");
} catch {
setErrorMessage("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
setErrorMessage(m.errorLogout);
}
};
@@ -139,8 +144,8 @@ export default function ProjectSubmissionsPage() {
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-xs text-slate-500 dark:text-slate-400"> .</p>
<h1 className="text-2xl font-bold tracking-tight">{m.headerTitle}</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">{m.headerDescription}</p>
<p className="mt-1 text-[11px] font-mono text-slate-500 dark:text-slate-400">{slug}</p>
</div>
<div className="flex items-center gap-3 text-sm">
@@ -150,21 +155,21 @@ export default function ProjectSubmissionsPage() {
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></span>
<span>{t.navDashboard}</span>
</Link>
<Link
href="/projects"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navProjects}</span>
</Link>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-emerald-700 hover:bg-emerald-50 hover:text-emerald-800 dark:text-emerald-200 dark:hover:bg-emerald-900/60 dark:hover:text-emerald-100"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.navSubmissions}</span>
</Link>
</nav>
<button
@@ -173,7 +178,7 @@ export default function ProjectSubmissionsPage() {
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </span>
<span>{t.themeToggleLabel}</span>
</button>
<div className="relative">
<button
@@ -183,7 +188,7 @@ export default function ProjectSubmissionsPage() {
setIsMenuOpen((prev) => !prev);
}}
>
{t.menuLabel}
</button>
{isMenuOpen && (
<div className="absolute right-0 mt-1 w-32 rounded border border-slate-200 bg-white shadow-lg z-20 dark:border-slate-700 dark:bg-slate-900/95">
@@ -195,7 +200,7 @@ export default function ProjectSubmissionsPage() {
void handleLogout();
}}
>
{t.logoutLabel}
</button>
</div>
)}
@@ -209,23 +214,23 @@ export default function ProjectSubmissionsPage() {
)}
{status === "loading" && (
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400"> ...</p>
<p className="text-xs text-slate-500 mb-3 dark:text-slate-400">{m.loadingText}</p>
)}
{submissions.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-500 dark:text-slate-400"> .</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{m.emptyText}</p>
)}
{submissions.length > 0 && (
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
<th className="py-2 pr-4"> </th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"></th>
<th className="py-2 pr-4"> </th>
<th className="py-2 pr-4">{m.tableHeaderCreatedAt}</th>
<th className="py-2 pr-4">{m.tableHeaderName}</th>
<th className="py-2 pr-4">{m.tableHeaderEmail}</th>
<th className="py-2 pr-4">{m.tableHeaderPhone}</th>
<th className="py-2 pr-4">{m.tableHeaderBirthdate}</th>
<th className="py-2 pr-4">{m.tableHeaderOtherFields}</th>
</tr>
</thead>
<tbody>