Compare commits

...

9 Commits

Author SHA1 Message Date
jaybe 9c07756e42 테스트 오류 수정
CI / test (push) Successful in 5m26s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m54s
2025-12-09 20:46:11 +09:00
jaybe 23a08621db 테스트 오류 수정
CI / test (push) Successful in 5m19s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m59s
2025-12-09 20:20:47 +09:00
jaybe 676e58cad7 테마 적용
CI / test (push) Failing after 5m22s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Has been skipped
2025-12-09 18:53:21 +09:00
jaybe 9d8c4538c7 대시보드, GNB 추가
CI / test (push) Successful in 4m58s
CI / e2e (push) Has been skipped
CI / pr_and_merge (push) Successful in 1m45s
2025-12-08 06:59:52 +09:00
jaybe b40be693ee 슬러그 수정
CI / test (push) Successful in 4m56s
CI / e2e (push) Successful in 5m53s
CI / pr_and_merge (push) Has been skipped
2025-12-07 20:57:19 +09:00
jaybe da3d71d124 Merge pull request 'CI: feature/form-submissions-pages' (#30) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m49s
CI / e2e (push) Successful in 5m53s
CI / pr_and_merge (push) Has been skipped
2025-12-07 10:53:30 +00:00
jaybe 5e8d0f4921 Merge pull request 'CI: feature/form-submissions-pages' (#29) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m42s
CI / e2e (push) Successful in 5m41s
CI / pr_and_merge (push) Has been skipped
2025-12-07 05:05:52 +00:00
jaybe 39ca7769fa Merge pull request 'CI: feature/form-submissions-pages' (#28) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m35s
CI / e2e (push) Failing after 5m40s
CI / pr_and_merge (push) Has been skipped
2025-12-07 03:07:58 +00:00
jaybe a385ed5de3 Merge pull request 'CI: feature/form-submissions-pages' (#27) from feature/form-submissions-pages into main
CI / test (push) Successful in 4m38s
CI / e2e (push) Failing after 6m1s
CI / pr_and_merge (push) Has been skipped
2025-12-07 01:18:45 +00:00
79 changed files with 4990 additions and 654 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+220
View File
@@ -0,0 +1,220 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { verifyAccessToken } from "@/features/auth/authCrypto";
// 대시보드 개요 API (/api/dashboard/overview)
// - 로그인한 사용자의 프로젝트/폼 제출 통계를 집계해 요약/프로젝트별 지표를 반환한다.
// - DB 스키마: Project, FormSubmission
// - 인증: pb_access 쿠키(JWT)를 검증해 현재 사용자 식별
const prisma = new PrismaClient();
interface AuthUser {
id: string;
email: string;
tokenVersion: number;
}
// 공통: pb_access 쿠키에서 JWT 토큰을 추출한다.
function extractTokenFromCookieHeader(cookieHeader: string | null): string | null {
if (!cookieHeader) return null;
const parts = cookieHeader.split(";");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed.startsWith("pb_access=")) {
const value = trimmed.substring("pb_access=".length);
return decodeURIComponent(value);
}
}
return null;
}
// 공통: Request 헤더의 쿠키에서 인증된 유저 정보를 복원한다.
async function getAuthUserFromRequest(request: Request): Promise<AuthUser | null> {
const cookieHeader = request.headers.get("cookie");
const token = extractTokenFromCookieHeader(cookieHeader);
if (!token) return null;
const payload = await verifyAccessToken(token);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email,
tokenVersion: payload.tokenVersion,
};
}
interface SummaryStats {
totalProjects: number;
totalSubmissions: number;
todaySubmissions: number;
last7DaysSubmissions: number;
}
interface DailySubmissionItem {
// 일별 제출 수 그래프를 위한 집계 데이터
date: string; // YYYY-MM-DD (UTC 기준)
count: number;
}
interface ProjectStatsItem {
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: string | null;
}
// GET /api/dashboard/overview
// - 현재 로그인 사용자의 프로젝트 목록/폼 제출 내역을 조회해 통계를 계산한다.
export async function GET(request: Request) {
const authUser = await getAuthUserFromRequest(request);
if (!authUser) {
return NextResponse.json(
{ message: "대시보드를 조회하려면 로그인이 필요합니다." },
{ status: 401 },
);
}
try {
// 현재 사용자 소유 프로젝트와, 해당 사용자가 소유한 제출 내역을 한 번에 로드한다.
const [projects, submissions] = await Promise.all([
prisma.project.findMany({
where: { userId: authUser.id },
select: {
id: true,
title: true,
slug: true,
status: true,
},
}),
prisma.formSubmission.findMany({
where: { userId: authUser.id },
select: {
id: true,
projectId: true,
projectSlug: true,
createdAt: true,
},
}),
]);
const totalProjects = projects.length;
const totalSubmissions = submissions.length;
// 오늘/최근 7일 기준 시각 계산
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
let todaySubmissions = 0;
let last7DaysSubmissions = 0;
for (const submission of submissions) {
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
if (createdAt >= startOfToday) {
todaySubmissions += 1;
}
if (createdAt >= sevenDaysAgo) {
last7DaysSubmissions += 1;
}
}
// 프로젝트별 통계 맵을 구성한다.
const statsByProjectId = new Map<
string,
{
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: Date | null;
}
>();
for (const project of projects) {
statsByProjectId.set(project.id, {
projectId: project.id,
title: project.title,
slug: project.slug,
status: project.status,
totalSubmissions: 0,
lastSubmissionAt: null,
});
}
for (const submission of submissions) {
const stat = submission.projectId ? statsByProjectId.get(submission.projectId) : null;
if (!stat) continue;
stat.totalSubmissions += 1;
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
if (!stat.lastSubmissionAt || createdAt > stat.lastSubmissionAt) {
stat.lastSubmissionAt = createdAt;
}
}
const projectStats: ProjectStatsItem[] = Array.from(statsByProjectId.values()).map((item) => ({
projectId: item.projectId,
title: item.title,
slug: item.slug,
status: item.status,
totalSubmissions: item.totalSubmissions,
lastSubmissionAt: item.lastSubmissionAt ? item.lastSubmissionAt.toISOString() : null,
}));
const summaryStats: SummaryStats = {
totalProjects,
totalSubmissions,
todaySubmissions,
last7DaysSubmissions,
};
// 일별 전체 제출 수를 집계해 그래프용 데이터로 변환한다.
const countsByDate = new Map<string, number>();
for (const submission of submissions) {
const createdAt = submission.createdAt instanceof Date
? submission.createdAt
: new Date(submission.createdAt as unknown as string);
const dateKey = createdAt.toISOString().slice(0, 10); // YYYY-MM-DD
const prev = countsByDate.get(dateKey) ?? 0;
countsByDate.set(dateKey, prev + 1);
}
const dailySubmissions: DailySubmissionItem[] = Array.from(countsByDate.entries())
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date));
return NextResponse.json(
{
summaryStats,
projectStats,
dailySubmissions,
},
{ status: 200 },
);
} catch (error: any) {
return NextResponse.json(
{
message: "대시보드 데이터를 계산하는 중 오류가 발생했습니다.",
error: error?.message,
},
{ status: 500 },
);
}
}
+13 -4
View File
@@ -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()
+345
View File
@@ -0,0 +1,345 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
// 대시보드 페이지 (/dashboard)
// - /api/dashboard/overview 에서 요약/프로젝트별 통계를 가져와 카드 형태로 렌더링한다.
// - 비로그인(401) 응답 시 로그인 페이지로 리다이렉트하고 에러 메시지를 표시한다.
interface SummaryStats {
totalProjects: number;
totalSubmissions: number;
todaySubmissions: number;
last7DaysSubmissions: number;
}
interface DailySubmissionItem {
// 일별 제출 수 그래프를 위한 집계 데이터
date: string; // YYYY-MM-DD
count: number;
}
interface ProjectStatsItem {
projectId: string;
title: string;
slug: string;
status: string;
totalSubmissions: number;
lastSubmissionAt: string | null;
}
type PageStatus = "idle" | "loading" | "error";
export default function DashboardPage() {
const router = useRouter();
const [summary, setSummary] = useState<SummaryStats | null>(null);
const [projects, setProjects] = useState<ProjectStatsItem[]>([]);
const [dailySubmissions, setDailySubmissions] = useState<DailySubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// 마운트 시 대시보드 개요 데이터를 불러온다.
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
setStatus("loading");
setErrorMessage(null);
const res = await fetch("/api/dashboard/overview");
if (!res.ok) {
if (cancelled) return;
if (res.status === 401) {
setStatus("error");
setErrorMessage("대시보드를 보려면 로그인이 필요합니다. 다시 로그인해 주세요.");
router.push("/login");
return;
}
setStatus("error");
setErrorMessage("대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
return;
}
const data = (await res.json()) as {
summaryStats: SummaryStats;
projectStats: ProjectStatsItem[];
dailySubmissions?: DailySubmissionItem[];
};
if (cancelled) return;
setSummary(data.summaryStats ?? null);
setProjects(Array.isArray(data.projectStats) ? data.projectStats : []);
setDailySubmissions(
Array.isArray(data.dailySubmissions) ? data.dailySubmissions : [],
);
setStatus("idle");
setErrorMessage(null);
} catch {
if (!cancelled) {
setStatus("error");
setErrorMessage(
"대시보드 데이터를 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
);
}
}
};
void load();
return () => {
cancelled = true;
};
}, [router]);
const safeSummary: SummaryStats =
summary ?? {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
};
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", {
method: "POST",
});
if (!res.ok) {
return;
}
router.push("/login");
} catch {
}
};
const handleToggleTheme = () => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
};
return (
<main className="min-h-screen flex flex-col bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<header className="border-b border-slate-200 px-6 py-4 flex items-center justify-between bg-white/80 backdrop-blur dark:border-slate-800 dark:bg-slate-950/80">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-slate-500 mt-1 dark:text-slate-400"> .</p>
</div>
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<Link
href="/dashboard"
aria-current="page"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></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}
>
<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 space-y-4 overflow-auto bg-slate-50/60 dark:bg-transparent">
{status === "error" && errorMessage && (
<p className="text-xs text-red-500 dark:text-red-300">{errorMessage}</p>
)}
{status === "loading" && (
<p className="text-xs text-slate-400"> ...</p>
)}
{/* 상단 요약 위젯 영역 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div
data-testid="dashboard-summary-total-projects"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> </span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalProjects}</span>
</div>
<div
data-testid="dashboard-summary-total-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> </span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.totalSubmissions}</span>
</div>
<div
data-testid="dashboard-summary-today-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> </span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.todaySubmissions}</span>
</div>
<div
data-testid="dashboard-summary-last7days-submissions"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400"> 7 </span>
<span className="text-2xl font-bold tracking-tight">{safeSummary.last7DaysSubmissions}</span>
</div>
</div>
{/* 일별 제출 수 간단 그래프 영역 */}
<div
data-testid="dashboard-daily-chart"
className="mt-2 rounded-xl border border-slate-200 bg-white/80 px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-200"> ()</h2>
<span className="text-[10px] text-slate-500 dark:text-slate-500"> .</span>
</div>
{dailySubmissions.length === 0 ? (
<p className="text-[11px] text-slate-500 dark:text-slate-400"> .</p>
) : (
<div className="flex items-end gap-2 text-[10px]">
{dailySubmissions.map((item) => (
<div
key={item.date}
data-testid="dashboard-daily-chart-bar"
className="flex flex-col items-center gap-1"
>
<div
className="w-6 rounded-t bg-emerald-500/80"
style={{ height: `${Math.max(8, item.count * 12)}px` }}
/>
<span className="text-slate-700 font-semibold dark:text-slate-300">{item.count}</span>
<span className="text-slate-500 dark:text-slate-500">{item.date}</span>
</div>
))}
</div>
)}
</div>
{/* 프로젝트 카드 리스트 영역 */}
<div className="mt-2">
<h2 className="text-base font-semibold mb-2 text-slate-800 dark:text-slate-100"> </h2>
{projects.length === 0 && status === "idle" && !errorMessage && (
<p className="text-xs text-slate-500 dark:text-slate-400"> .</p>
)}
{projects.length > 0 && (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => {
const lastSubmitted = project.lastSubmissionAt
? new Date(project.lastSubmissionAt).toLocaleString()
: "제출 내역 없음";
return (
<article
key={project.projectId}
data-testid="dashboard-project-card"
className="rounded-xl border border-slate-200 bg-white/80 px-4 py-3 flex flex-col gap-1 shadow-sm dark:border-slate-800 dark:bg-slate-900/70"
>
<div className="flex items-baseline justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-50">{project.title}</h3>
<p className="text-[11px] text-slate-500 font-mono dark:text-slate-400">{project.slug}</p>
</div>
<span className="text-[10px] px-2 py-0.5 rounded-full border border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-transparent dark:text-slate-300">
{project.status}
</span>
</div>
<div className="mt-1 flex items-center justify-between text-[11px] text-slate-700 dark:text-slate-300">
<span>
<span className="font-semibold">{project.totalSubmissions}</span>
</span>
<span className="text-slate-500 dark:text-slate-400"> : {lastSubmitted}</span>
</div>
<div className="mt-2 flex items-center gap-2 text-[11px]">
<Link
href={`/projects/${encodeURIComponent(project.slug)}/submissions`}
className="text-emerald-700 hover:text-emerald-900 underline-offset-2 hover:underline dark:text-emerald-300 dark:hover:text-emerald-100"
>
</Link>
<Link
href={`/p/${encodeURIComponent(project.slug)}`}
className="text-sky-700 hover:text-sky-900 underline-offset-2 hover:underline dark:text-sky-300 dark:hover:text-sky-200"
>
</Link>
</div>
</article>
);
})}
</div>
)}
</div>
</section>
</main>
);
}
+7 -3
View File
@@ -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, {
+34 -34
View File
@@ -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, {
+48 -54
View File
@@ -226,24 +226,11 @@ function EditorPageInner() {
return;
}
const now = new Date();
const datePart = [
now.getFullYear().toString().padStart(4, "0"),
(now.getMonth() + 1).toString().padStart(2, "0"),
now.getDate().toString().padStart(2, "0"),
].join("");
const randomSlug = Array.from({ length: 12 }, () =>
Math.floor(Math.random() * 36).toString(36),
).join("");
const timePart = [
now.getHours().toString().padStart(2, "0"),
now.getMinutes().toString().padStart(2, "0"),
now.getSeconds().toString().padStart(2, "0"),
].join("");
const randomPart = Math.random().toString(36).slice(2, 6);
const generatedSlug = `page-${datePart}-${timePart}-${randomPart}`;
updateProjectConfig({ slug: generatedSlug });
updateProjectConfig({ slug: randomSlug });
}, [searchParams, projectConfig?.slug, updateProjectConfig]);
useEffect(() => {
@@ -1083,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" />
@@ -1246,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)}
>
@@ -1259,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 })}
/>
@@ -1277,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>
@@ -1302,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)}
>
@@ -1317,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}
>
@@ -1332,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
@@ -1522,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;
@@ -1554,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}
@@ -1623,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>
)
)}
{(() => {
@@ -1728,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
@@ -1805,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
@@ -1876,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}>
@@ -1890,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
@@ -2063,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
@@ -2074,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>
)}
@@ -2175,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"
@@ -2380,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>
) : (
+52 -49
View File
@@ -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]">
+16 -16
View File
@@ -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 })}
+6 -6
View File
@@ -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) =>
+1 -1
View File
@@ -10,7 +10,7 @@ export const metadata = {
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="ko" suppressHydrationWarning>
<body className="min-h-screen bg-slate-950 text-slate-50">
<body className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
{children}
</body>
</html>
+36 -12
View File
@@ -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;
@@ -23,7 +37,7 @@ export default function LoginPage() {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
router.push("/dashboard");
}
} catch {
// ignore
@@ -61,8 +75,8 @@ export default function LoginPage() {
return;
}
// 성공 시에는 /projects 로 이동한다.
router.push("/projects");
// 성공 시에는 /dashboard 로 이동한다.
router.push("/dashboard");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
@@ -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">
+14 -7
View File
@@ -1,12 +1,19 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function HomePage() {
const router = useRouter();
useEffect(() => {
// 메인 페이지 진입 시 대시보드로 안내한다.
router.push("/dashboard");
}, [router]);
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-3xl font-bold">Page Builder MVP</h1>
<p className="text-sm text-slate-400">
TDD로 .
</p>
</div>
<main className="flex min-h-screen items-center justify-center bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50">
<p className="text-sm text-slate-500 dark:text-slate-400"> ...</p>
</main>
);
}
+13 -10
View File
@@ -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>
+109 -28
View File
@@ -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>
+123 -57
View File
@@ -11,6 +11,9 @@ import {
ListChecks,
ChevronLeft,
ChevronRight,
LayoutDashboard,
FolderKanban,
SunMoon,
} from "lucide-react";
interface ProjectListItem {
@@ -29,6 +32,7 @@ export default function ProjectsPage() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [selectedSlugs, setSelectedSlugs] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const pageSize = 10;
useEffect(() => {
@@ -179,7 +183,7 @@ export default function ProjectsPage() {
window.localStorage.removeItem(`pb:autosave:${slug}`);
}
}
if (okSlugs.length !== slugs.length) {
setStatus("error");
setErrorMessage(
@@ -195,74 +199,133 @@ export default function ProjectsPage() {
}
};
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-sm text-slate-500 dark:text-slate-400"> </p>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-3 text-sm">
<nav className="inline-flex items-center gap-1 rounded-full border border-slate-200 bg-white/80 px-1.5 py-1 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
<Link
href="/dashboard"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 hover:text-sky-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:hover:text-sky-200"
>
<LayoutDashboard className="w-4 h-4" aria-hidden="true" />
<span></span>
</Link>
<Link
href="/projects"
aria-current="page"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-sky-600 text-white shadow-sm hover:bg-sky-700"
>
<FolderKanban className="w-4 h-4" aria-hidden="true" />
<span> </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 border border-slate-700 px-3 py-1 text-slate-200 hover:bg-slate-900"
onClick={() => {
void handleLogout();
}}
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={handleToggleTheme}
>
<SunMoon className="w-4 h-4" aria-hidden="true" />
<span> </span>
</button>
<Link
href="/projects/submissions"
className="inline-flex items-center gap-1 rounded border border-emerald-700 bg-emerald-950 px-3 py-1 text-emerald-100 hover:bg-emerald-900 shadow-sm"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </span>
</Link>
<Link
href="/editor?new=1"
className="inline-flex items-center gap-1 rounded border border-sky-700 bg-sky-950 px-3 py-1 text-sky-100 hover:bg-sky-900 shadow-sm"
>
<FilePlus2 className="w-4 h-4" aria-hidden="true" />
<span> </span>
</Link>
<div 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" && (
<p className="text-xs text-red-300 mb-3">
<p className="text-xs text-red-500 mb-3 dark:text-red-300">
{errorMessage ?? "프로젝트 목록을 불러오는 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요."}
</p>
)}
{projects.length === 0 && status === "idle" && (
<p className="text-xs text-slate-400"> . .</p>
<p className="text-xs text-slate-500 dark:text-slate-400"> . .</p>
)}
{projects.length > 0 && (
<>
<div className="flex items-center justify-between mb-2 text-[11px] text-slate-300">
<div
data-testid="projects-toolbar"
className="flex items-center justify-between mb-2 text-[11px] text-slate-600 dark:text-slate-300"
>
<div className="inline-flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full bg-slate-900/70 border border-slate-700">
<ListChecks className="w-3 h-3 text-slate-400" aria-hidden="true" />
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full border bg-slate-100 border-slate-300 text-slate-700 dark:bg-slate-900/70 dark:border-slate-700 dark:text-slate-200">
<ListChecks className="w-3 h-3 text-slate-400 dark:text-slate-500" aria-hidden="true" />
<span>
: <span className="font-semibold">{selectedSlugs.length}</span>
</span>
</span>
</div>
<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"
disabled={selectedSlugs.length === 0}
onClick={() => {
void handleBulkDelete();
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<div className="flex items-center gap-2">
<button
type="button"
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-red-300 text-red-700 bg-red-50 hover:bg-red-100 disabled:opacity-40 disabled:cursor-not-allowed dark:border-red-700 dark:text-red-200 dark:bg-red-900/40 dark:hover:bg-red-900/60"
disabled={selectedSlugs.length === 0}
onClick={() => {
void handleBulkDelete();
}}
>
<Trash2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</button>
<Link
href="/editor?new=1"
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs font-medium border-sky-500 bg-sky-600 text-white hover:bg-sky-700 shadow-sm dark:border-sky-700 dark:bg-sky-950 dark:text-sky-100 dark:hover:bg-sky-900"
>
<FilePlus2 className="w-3 h-3" aria-hidden="true" />
<span> </span>
</Link>
</div>
</div>
<table className="w-full text-xs text-left border-collapse mt-2">
<thead>
<tr className="border-b border-slate-800 text-slate-400">
<tr className="border-b border-slate-200 text-slate-600 dark:border-slate-800 dark:text-slate-400">
<th className="py-2 pr-2 w-8">
<input
type="checkbox"
@@ -294,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"
@@ -312,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]">
@@ -325,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);
}}
@@ -362,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)}
@@ -383,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}
@@ -395,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" />
@@ -404,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>
+108 -28
View File
@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FolderKanban, LayoutDashboard, ListChecks, SunMoon } from "lucide-react";
interface SubmissionItem {
id: string;
@@ -18,6 +20,7 @@ export default function AllProjectSubmissionsPage() {
const [submissions, setSubmissions] = useState<SubmissionItem[]>([]);
const [status, setStatus] = useState<PageStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
let cancelled = false;
@@ -82,45 +85,119 @@ export default function AllProjectSubmissionsPage() {
.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("로그아웃 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
}
};
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-sm text-slate-500 dark:text-slate-400"> .</p>
</div>
<div className="text-xs text-slate-300 flex flex-col items-end gap-1">
<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"
aria-current="page"
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-semibold bg-emerald-600 text-white shadow-sm hover:bg-emerald-700"
>
<ListChecks className="w-4 h-4" aria-hidden="true" />
<span> </span>
</Link>
</nav>
<button
type="button"
className="inline-flex items-center gap-1 rounded-full border border-slate-300 bg-white/80 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={() => {
if (typeof document === "undefined") {
return;
}
const root = document.documentElement;
if (!root) {
return;
}
root.classList.toggle("dark");
}}
>
</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">Slug</th>
@@ -146,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>
+87 -13
View File
@@ -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);
@@ -23,7 +45,7 @@ export default function SignupPage() {
try {
const res = await fetch("/api/auth/me");
if (!cancelled && res.ok) {
router.push("/projects");
router.push("/dashboard");
}
} catch {
// ignore
@@ -41,6 +63,11 @@ export default function SignupPage() {
e.preventDefault();
setError(null);
if (password !== passwordConfirm) {
setError("비밀번호와 비밀번호 확인이 일치하지 않습니다.");
return;
}
setLoading(true);
try {
@@ -61,7 +88,7 @@ export default function SignupPage() {
return;
}
router.push("/projects");
router.push("/dashboard");
} catch {
setError("네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.");
} finally {
@@ -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>
);
}
+4 -4
View File
@@ -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
View File
@@ -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 커스텀 프로퍼티로 제어한다.
+55
View File
@@ -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
View File
@@ -1,3 +1,4 @@
@config "../../tailwind.config.js";
@import "tailwindcss";
html,
+1
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: [
"./src/app/**/*.{ts,tsx}",
"./src/components/**/*.{ts,tsx}",
+220
View File
@@ -0,0 +1,220 @@
import "dotenv/config";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { signAccessToken } from "@/features/auth/authCrypto";
// /api/dashboard/overview TDD
// - 로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다.
// - 로그인하지 않은 경우 401 을 반환해야 한다.
// - 프로젝트/제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다.
const BASE_URL = "http://localhost";
const inMemoryProjects: any[] = [];
const inMemoryFormSubmissions: any[] = [];
vi.mock("@prisma/client", () => {
class PrismaClientMock {
project = {
findMany: async (args: any = {}) => {
const where = args.where ?? {};
let items = [...inMemoryProjects];
if (where.userId) {
items = items.filter((p) => p.userId === where.userId);
}
return items;
},
};
formSubmission = {
findMany: async (args: any = {}) => {
const where = args.where ?? {};
let items = [...inMemoryFormSubmissions];
if (where.userId) {
items = items.filter((s) => s.userId === where.userId);
}
return items;
},
};
}
return { PrismaClient: PrismaClientMock };
});
const TEST_USER = { id: "user-1", email: "dashboard@example.com", tokenVersion: 1 };
const OTHER_USER = { id: "user-2", email: "dashboard2@example.com", tokenVersion: 1 };
async function buildAuthHeadersFor(user: { id: string; email: string; tokenVersion: number }) {
const token = await signAccessToken(user);
return { cookie: `pb_access=${token}` };
}
async function buildAuthHeaders() {
return buildAuthHeadersFor(TEST_USER);
}
beforeEach(() => {
process.env.AUTH_JWT_SECRET = "test-jwt-secret-dashboard";
inMemoryProjects.length = 0;
inMemoryFormSubmissions.length = 0;
});
describe("/api/dashboard/overview", () => {
it("로그인한 사용자가 자신의 프로젝트/폼 제출 통계를 조회할 수 있어야 한다", async () => {
const project1 = {
id: "proj-1",
userId: TEST_USER.id,
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
};
const project2 = {
id: "proj-2",
userId: TEST_USER.id,
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
};
const projectOther = {
id: "proj-3",
userId: OTHER_USER.id,
title: "다른 유저 프로젝트",
slug: "other-project",
status: "DRAFT",
};
inMemoryProjects.push(project1, project2, projectOther);
const now = new Date("2025-01-03T12:00:00.000Z");
const yesterday = new Date("2025-01-02T12:00:00.000Z");
const eightDaysAgo = new Date("2024-12-26T12:00:00.000Z");
inMemoryFormSubmissions.push(
{
id: "sub-1",
projectId: project1.id,
projectSlug: project1.slug,
userId: TEST_USER.id,
createdAt: now,
payloadJson: { message: "A-1" },
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-2",
projectId: project1.id,
projectSlug: project1.slug,
userId: TEST_USER.id,
createdAt: yesterday,
payloadJson: { message: "A-2" },
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-3",
projectId: project2.id,
projectSlug: project2.slug,
userId: TEST_USER.id,
createdAt: eightDaysAgo,
payloadJson: { message: "B-1" },
sensitiveEnc: undefined,
metaJson: null,
},
{
id: "sub-4",
projectId: projectOther.id,
projectSlug: projectOther.slug,
userId: OTHER_USER.id,
createdAt: now,
payloadJson: { message: "OTHER" },
sensitiveEnc: undefined,
metaJson: null,
},
);
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
const headers = await buildAuthHeaders();
const res = await getDashboardOverview(
new Request(`${BASE_URL}/api/dashboard/overview`, {
headers,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.summaryStats.totalProjects).toBe(2);
expect(json.summaryStats.totalSubmissions).toBe(3);
expect(Array.isArray(json.projectStats)).toBe(true);
expect(json.projectStats.length).toBe(2);
const proj1Stats = json.projectStats.find((p: any) => p.slug === project1.slug);
const proj2Stats = json.projectStats.find((p: any) => p.slug === project2.slug);
expect(proj1Stats).toBeTruthy();
expect(proj1Stats.totalSubmissions).toBe(2);
expect(proj1Stats.lastSubmissionAt).toBe(now.toISOString());
expect(proj2Stats).toBeTruthy();
expect(proj2Stats.totalSubmissions).toBe(1);
expect(proj2Stats.lastSubmissionAt).toBe(eightDaysAgo.toISOString());
const daily = (json as any).dailySubmissions;
expect(Array.isArray(daily)).toBe(true);
expect(daily.length).toBe(3);
const dates = daily.map((item: any) => item.date).sort();
expect(dates).toEqual(["2024-12-26", "2025-01-02", "2025-01-03"]);
const countsByDate = (daily as any[]).reduce((acc: Record<string, number>, item: any) => {
acc[item.date] = item.count;
return acc;
}, {} as Record<string, number>);
expect(countsByDate["2025-01-03"]).toBe(1);
expect(countsByDate["2025-01-02"]).toBe(1);
expect(countsByDate["2024-12-26"]).toBe(1);
});
it("로그인하지 않은 경우 401 을 반환해야 한다", async () => {
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
const res = await getDashboardOverview(
new Request(`${BASE_URL}/api/dashboard/overview`),
);
expect(res.status).toBe(401);
});
it("프로젝트와 제출 내역이 없는 경우 0 값과 빈 배열을 반환해야 한다", async () => {
const { GET: getDashboardOverview } = await import("@/app/api/dashboard/overview/route");
const headers = await buildAuthHeaders();
const res = await getDashboardOverview(
new Request(`${BASE_URL}/api/dashboard/overview`, {
headers,
}),
);
expect(res.status).toBe(200);
const json = (await res.json()) as any;
expect(json.summaryStats.totalProjects).toBe(0);
expect(json.summaryStats.totalSubmissions).toBe(0);
expect(Array.isArray(json.projectStats)).toBe(true);
expect(json.projectStats.length).toBe(0);
});
});
+16
View File
@@ -259,6 +259,22 @@ describe("/api/export", () => {
expect(htmlColors).toContain('style="background-color:#222222;margin:0;padding:0;"');
});
it("bodyBgColorHex 가 빈 문자열이면 buildStaticHtml 의 body style 에 background-color 가 포함되지 않아야 한다", () => {
const blocks: Block[] = [];
const projectConfig: ProjectConfig = {
title: "배경색 비어 있음 테스트",
slug: "body-bg-empty-test",
canvasPreset: "full",
canvasBgColorHex: "#0f172a",
bodyBgColorHex: "",
} as ProjectConfig;
const html = buildStaticHtml(blocks, projectConfig);
// body style 에 background-color 가 없어야 한다.
expect(html).toContain('<body style="margin:0;padding:0;"');
});
it("projectConfig.headHtml / trackingScript 가 head 와 body 에 반영되어야 한다", async () => {
const blocks: Block[] = [];
+6 -5
View File
@@ -48,7 +48,7 @@ test("비로그인 사용자가 /preview 에 접근하면 /login 으로 리다
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
test("회원가입 후에는 /dashboard, /editor, /preview 에 정상 접근할 수 있어야 한다", async ({ page }) => {
const email = `e2e+${Date.now()}@example.com`;
const password = "securePass1";
@@ -95,12 +95,13 @@ test("회원가입 후에는 /projects, /editor, /preview 에 정상 접근할
await page.goto("/signup");
await page.getByLabel("이메일").fill(email);
await page.getByLabel("비밀번호").fill(password);
await page.getByLabel("비밀번호", { exact: true }).fill(password);
await page.getByLabel("비밀번호 확인").fill(password);
await page.getByRole("button", { name: "회원가입" }).click();
// /projects 로 이동했는지, 헤더가 보이는지 확인
await expect(page).toHaveURL(/\/projects/);
await expect(page.getByRole("heading", { name: "프로젝트 목록" })).toBeVisible();
// /dashboard 로 이동했는지, 헤더가 보이는지 확인
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
// 2) /editor 접근 확인 – 로그인 페이지로 리다이렉트되면 안 된다.
await page.goto("/editor");
+140
View File
@@ -0,0 +1,140 @@
import { test, expect } from "@playwright/test";
// 대시보드 E2E TDD
// - 비로그인 사용자는 /dashboard 접근 시 /login 으로 리다이렉트되어야 한다.
// - 로그인 사용자는 /dashboard 에서 요약 지표 + 프로젝트 카드 리스트를 볼 수 있어야 한다.
// - 백엔드/DB 에 의존하지 않도록 /api/dashboard/overview 를 Playwright 라우팅으로 목킹한다.
test("비로그인 사용자가 /dashboard 에 접근하면 /login 으로 리다이렉트되어야 한다", async ({ page }) => {
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "로그인이 필요합니다." }),
});
});
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
test("로그인 사용자가 /dashboard 에 접근하면 요약 지표와 프로젝트 카드 리스트, 일별 그래프를 볼 수 있어야 한다", async ({ page }) => {
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
summaryStats: {
totalProjects: 2,
totalSubmissions: 3,
todaySubmissions: 1,
last7DaysSubmissions: 3,
},
projectStats: [
{
projectId: "proj-1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
totalSubmissions: 2,
lastSubmissionAt: "2025-01-02T12:00:00.000Z",
},
{
projectId: "proj-2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
totalSubmissions: 1,
lastSubmissionAt: "2025-01-03T09:30:00.000Z",
},
],
dailySubmissions: [
{ date: "2024-12-26", count: 1 },
{ date: "2025-01-02", count: 1 },
{ date: "2025-01-03", count: 1 },
],
}),
});
});
await page.goto("/dashboard");
// 대시보드 헤더가 보여야 한다.
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
// 상단 헤더 내비게이션이 보여야 한다.
const dashboardLink = page.getByRole("link", { name: "대시보드" });
await expect(dashboardLink).toBeVisible();
const projectsLink = page.getByRole("link", { name: "프로젝트 목록" });
await expect(projectsLink).toBeVisible();
const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" });
await expect(submissionsLink).toBeVisible();
const menuButton = page.getByRole("button", { name: "메뉴" });
await expect(menuButton).toBeVisible();
// 상단 요약 위젯의 지표들이 기대 값으로 렌더되어야 한다.
const totalProjects = page.getByTestId("dashboard-summary-total-projects");
await expect(totalProjects).toContainText("2");
const totalSubmissions = page.getByTestId("dashboard-summary-total-submissions");
await expect(totalSubmissions).toContainText("3");
const todaySubmissions = page.getByTestId("dashboard-summary-today-submissions");
await expect(todaySubmissions).toContainText("1");
const last7DaysSubmissions = page.getByTestId(
"dashboard-summary-last7days-submissions",
);
await expect(last7DaysSubmissions).toContainText("3");
// 프로젝트 카드 리스트가 2개 렌더링되어야 한다.
const projectCards = page.getByTestId("dashboard-project-card");
await expect(projectCards).toHaveCount(2);
await expect(projectCards.nth(0)).toContainText("테스트 프로젝트 A");
await expect(projectCards.nth(0)).toContainText("test-project-a");
await expect(projectCards.nth(0)).toContainText("2");
await expect(projectCards.nth(1)).toContainText("테스트 프로젝트 B");
await expect(projectCards.nth(1)).toContainText("test-project-b");
await expect(projectCards.nth(1)).toContainText("1");
// 일별 제출 수 그래프가 렌더링되어야 한다.
const dailyChart = page.getByTestId("dashboard-daily-chart");
await expect(dailyChart).toBeVisible();
const dailyBars = page.getByTestId("dashboard-daily-chart-bar");
await expect(dailyBars).toHaveCount(3);
// 메뉴 버튼을 클릭하면 로그아웃 항목이 보여야 한다.
await menuButton.click();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
});
test("로그인 사용자가 / 에 접속하면 대시보드로 진입해야 한다", async ({ page }) => {
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
summaryStats: {
totalProjects: 1,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
}),
});
});
await page.goto("/");
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
});
+104 -1
View File
@@ -219,7 +219,110 @@ test("프로젝트 목록에서 '폼 제출 내역' 링크를 클릭하면 해
await expect(page.getByText("홍길동")).toBeVisible();
await expect(page.getByText("user@example.com")).toBeVisible();
await expect(page.getByText("010-1234-5678")).toBeVisible();
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
await expect(page.getByText("1990-01-01")).toBeVisible();
await expect(page.getByText("message: 안녕하세요")).toBeVisible();
});
test("프로젝트 목록 헤더에서 대시보드로 이동할 수 있어야 한다", async ({ page }) => {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ id: "user-e2e", email: "e2e@example.com", tokenVersion: 1 }),
});
});
await page.route("**/api/projects", async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
if (url.pathname === "/api/projects" && method === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
updatedAt: new Date("2025-01-01T00:00:00.000Z").toISOString(),
},
]),
});
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ message: "Not implemented in E2E mock" }),
});
});
await page.route("**/api/dashboard/overview", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
summaryStats: {
totalProjects: 1,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
}),
});
});
await page.goto("/projects");
await expect(page.getByText("테스트 프로젝트 A")).toBeVisible();
await page.getByRole("link", { name: "대시보드" }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: "대시보드" })).toBeVisible();
});
test("전체 제출 내역 페이지에서 공통 GNB와 메뉴가 보여야 한다", async ({ page }) => {
await page.route("**/api/projects/submissions", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([]),
});
});
await page.route("**/api/auth/logout", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "ok" }),
});
});
await page.goto("/projects/submissions");
await expect(page.getByRole("heading", { name: "전체 폼 제출 내역" })).toBeVisible();
const dashboardLink = page.getByRole("link", { name: "대시보드" });
await expect(dashboardLink).toBeVisible();
const projectsLink = page.getByRole("link", { name: "프로젝트 목록" });
await expect(projectsLink).toBeVisible();
const submissionsLink = page.getByRole("link", { name: "전체 제출 내역" });
await expect(submissionsLink).toBeVisible();
const menuButton = page.getByRole("button", { name: "메뉴" });
await expect(menuButton).toBeVisible();
await menuButton.click();
await expect(page.getByRole("button", { name: "로그아웃" })).toBeVisible();
});
+137 -2
View File
@@ -146,11 +146,146 @@ describe("AllProjectSubmissionsPage - 전체 폼 제출 내역", () => {
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
expect(backLink.closest("a")?.getAttribute("href")).toBe("/projects");
});
backLink.click();
it("상단 GNB 에 대시보드/프로젝트 목록/전체 제출 내역 링크가 있어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
expect(dashboardLink.closest("a")?.getAttribute("href")).toBe("/dashboard");
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects");
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(submissionsLink.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
it("전체 제출 내역 테이블은 라이트 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => {
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "proj-1",
projectTitle: "프로젝트 1",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
const { container } = render(<AllProjectSubmissionsPage />);
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const headerRow = container.querySelector("thead tr") as HTMLElement | null;
expect(headerRow).not.toBeNull();
const headerClass = headerRow!.className;
expect(headerClass).toContain("border-slate-200");
expect(headerClass).toContain("text-slate-600");
expect(headerClass).toContain("dark:border-slate-800");
expect(headerClass).toContain("dark:text-slate-400");
const nameCellNode = await screen.findByText("홍길동");
const nameCell = nameCellNode.closest("td") as HTMLElement | null;
expect(nameCell).not.toBeNull();
const nameClass = nameCell!.className;
expect(nameClass).toContain("text-slate-900");
expect(nameClass).toContain("dark:text-slate-100");
});
it("GNB에서 현재 페이지인 '전체 제출 내역' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(submissionsLink.getAttribute("href")).toBe("/projects/submissions");
expect(submissionsLink.getAttribute("aria-current")).toBe("page");
expect(dashboardLink.getAttribute("aria-current")).toBeNull();
expect(projectsLink.getAttribute("aria-current")).toBeNull();
});
it("상단 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/projects/submissions" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/logout" && method === "POST") {
return Promise.resolve(new Response(JSON.stringify({ message: "ok" }), { status: 200 }));
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<AllProjectSubmissionsPage />);
const menuButton = await screen.findByRole("button", { name: "메뉴" });
menuButton.click();
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
logoutButton.click();
await waitFor(() => {
const logoutCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/logout" && options?.method === "POST";
});
expect(logoutCall).toBeDefined();
expect(pushMock).toHaveBeenCalledWith("/login");
});
});
});
+72
View File
@@ -210,5 +210,77 @@ describe("BlocksSidebar - 템플릿 버튼", () => {
// 다시 클릭하면 펼쳐져야 한다.
fireEvent.click(templateToggle);
expect(screen.getByRole("button", { name: "Hero 템플릿" })).toBeDefined();
});
it("블록 추가 버튼들은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const textButton = screen.getByRole("button", { name: "텍스트" });
const className = textButton.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-900");
// 다크 모드: 기존 다크 스타일 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("히어로 템플릿 카드는 라이트/다크 테마에 맞는 카드 배경/테두리 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const heroDescription = screen.getByText(
"페이지 상단 Hero 섹션 (큰 제목 + 서브텍스트 + 버튼)",
);
const card = heroDescription.closest("div");
expect(card).not.toBeNull();
const className = card!.getAttribute("class") ?? "";
expect(className).toContain("bg-slate-50");
expect(className).toContain("border-slate-200");
expect(className).toContain("dark:border-slate-800");
expect(className).toContain("dark:bg-slate-950/50");
});
it("히어로 템플릿 추가 버튼은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<BlocksSidebar />);
const heroButton = screen.getByRole("button", { name: "Hero 템플릿" });
const className = heroButton.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-900");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("히어로 템플릿 썸네일 프레임은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
render(<BlocksSidebar />);
const thumb = screen.getByTestId("template-preview-hero") as HTMLDivElement;
const className = thumb.getAttribute("class") ?? "";
// 라이트 모드: 밝은 카드 배경 + 연한 테두리
expect(className).toContain("bg-slate-100");
expect(className).toContain("border-slate-300");
// 다크 모드: 기존 다크 카드 톤 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:border-slate-700");
});
it("사이드바 루트는 라이트/다크 테마에 맞는 배경/테두리 클래스를 사용해야 한다", () => {
const { container } = render(<BlocksSidebar />);
const aside = container.querySelector("aside") as HTMLElement | null;
expect(aside).not.toBeNull();
const className = aside!.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("border-slate-200");
expect(className).toContain("dark:border-slate-800");
expect(className).toContain("dark:bg-slate-950/40");
});
});
+101
View File
@@ -80,6 +80,46 @@ describe("ButtonPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, textColorCustom: "#ff0000" }}
selectedBlockId="btn-text-none"
updateBlock={updateBlock}
/>,
);
// 첫 번째 ColorPickerField(텍스트 색상)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다.
const noneButtons = screen.getAllByRole("button", { name: "없음" });
fireEvent.click(noneButtons[0]);
expect(updateBlock).toHaveBeenCalledWith(
"btn-text-none",
expect.objectContaining({ textColorCustom: "" }),
);
});
it("textColorCustom 이 비어 있으면 버튼 텍스트 색상 HEX 인풋 값은 빈 문자열이어야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={{ ...baseProps, textColorCustom: "" }}
selectedBlockId="btn-text-empty"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("버튼 텍스트 색상 HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
it("버튼 너비 모드 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
@@ -200,6 +240,28 @@ describe("ButtonPropertiesPanel", () => {
);
});
it("버튼 텍스트 textarea 는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={baseProps}
selectedBlockId="btn-theme"
updateBlock={updateBlock}
/>,
);
const textarea = screen.getByLabelText("버튼 텍스트") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("가로 패딩 슬라이더 변경 시 updateBlock 이 paddingX 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
@@ -399,4 +461,43 @@ describe("ButtonPropertiesPanel", () => {
expect.objectContaining({ letterSpacingCustom: "1em" }),
);
});
it("버튼 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ButtonPropertiesPanel
buttonProps={baseProps as any}
selectedBlockId="btn-theme"
updateBlock={updateBlock}
/>,
);
const labelTextarea = screen.getByLabelText("버튼 텍스트") as HTMLTextAreaElement;
const labelClass = labelTextarea.getAttribute("class") ?? "";
expect(labelClass).toContain("bg-white");
expect(labelClass).toContain("text-slate-900");
expect(labelClass).toContain("border-slate-300");
expect(labelClass).toContain("dark:bg-slate-900");
expect(labelClass).toContain("dark:text-slate-100");
expect(labelClass).toContain("dark:border-slate-700");
const imageSourceSelect = screen.getByLabelText("버튼 이미지 소스") as HTMLSelectElement;
const imageSourceClass = imageSourceSelect.getAttribute("class") ?? "";
expect(imageSourceClass).toContain("bg-white");
expect(imageSourceClass).toContain("text-slate-900");
expect(imageSourceClass).toContain("border-slate-300");
expect(imageSourceClass).toContain("dark:bg-slate-900");
expect(imageSourceClass).toContain("dark:text-slate-100");
expect(imageSourceClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("버튼 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ColorPickerField, TEXT_COLOR_PALETTE } from "@/features/editor/components/ColorPickerField";
describe("ColorPickerField - 테마", () => {
it("HEX 텍스트 인풋은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<ColorPickerField
label="색상"
ariaLabelColorInput="컬러 입력"
ariaLabelHexInput="HEX 입력"
value="#ff0000"
onChange={() => {}}
palette={TEXT_COLOR_PALETTE}
/>,
);
const hexInput = screen.getByLabelText("HEX 입력") as HTMLInputElement;
const className = hexInput.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("팔레트 드롭다운 요약 버튼은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<ColorPickerField
label="색상"
ariaLabelColorInput="컬러 입력"
ariaLabelHexInput="HEX 입력"
value="#ff0000"
onChange={() => {}}
palette={TEXT_COLOR_PALETTE}
/>,
);
const [label] = screen.getAllByText("색상 팔레트");
const button = label.closest("button") as HTMLButtonElement | null;
expect(button).not.toBeNull();
const className = button!.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("팔레트 드롭다운 항목은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
render(
<ColorPickerField
label="색상"
ariaLabelColorInput="컬러 입력"
ariaLabelHexInput="HEX 입력"
value="#ff0000"
onChange={() => {}}
palette={TEXT_COLOR_PALETTE}
/>,
);
// 드롭다운을 연다
const [label] = screen.getAllByText("색상 팔레트");
const toggleButton = label.closest("button") as HTMLButtonElement | null;
expect(toggleButton).not.toBeNull();
fireEvent.click(toggleButton!);
// 팔레트 첫 항목 버튼을 찾는다 (TEXT_COLOR_PALETTE[0] 라벨과 동기화)
const firstItem = TEXT_COLOR_PALETTE[0];
const itemButton = screen.getByRole("button", { name: firstItem.label }) as HTMLButtonElement;
const className = itemButton.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
// 색상 불렛(span)은 테두리를 가져야 한다 (흰색/투명 색상도 배경과 구분되도록)
const bullet = itemButton.querySelector("span span") as HTMLSpanElement | null;
expect(bullet).not.toBeNull();
const bulletClass = bullet!.getAttribute("class") ?? "";
expect(bulletClass).toContain("rounded-full");
expect(bulletClass).toContain("border");
expect(bulletClass).toContain("border-slate-300");
expect(bulletClass).toContain("dark:border-slate-700");
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup, waitFor } from "@testing-library/react";
import DashboardPage from "@/app/dashboard/page";
export const pushMock = vi.fn();
vi.mock("next/navigation", () => {
return {
__esModule: true,
useRouter: () => ({ push: pushMock }),
};
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("DashboardPage - GNB와 테마", () => {
it("GNB에서 현재 페이지인 '대시보드' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const overview = {
summaryStats: {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(overview), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<DashboardPage />);
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(dashboardLink.getAttribute("href")).toBe("/dashboard");
expect(dashboardLink.getAttribute("aria-current")).toBe("page");
expect(projectsLink.getAttribute("aria-current")).toBeNull();
expect(submissionsLink.getAttribute("aria-current")).toBeNull();
});
it("헤더에 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", async () => {
const overview = {
summaryStats: {
totalProjects: 0,
totalSubmissions: 0,
todaySubmissions: 0,
last7DaysSubmissions: 0,
},
projectStats: [],
dailySubmissions: [],
};
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(overview), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<DashboardPage />);
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
});
@@ -143,4 +143,43 @@ describe("DividerPropertiesPanel", () => {
expect.objectContaining({ marginYPx: 24 }),
);
});
it("DividerPropertiesPanel 주요 셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<DividerPropertiesPanel
dividerProps={baseProps}
selectedBlockId="divider-theme-1"
updateBlock={updateBlock}
/>,
);
const alignSelect = screen.getByLabelText("구분선 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
const thicknessSelect = screen.getByLabelText("구분선 두께") as HTMLSelectElement;
const thicknessClass = thicknessSelect.getAttribute("class") ?? "";
expect(thicknessClass).toContain("bg-white");
expect(thicknessClass).toContain("text-slate-900");
expect(thicknessClass).toContain("border-slate-300");
expect(thicknessClass).toContain("dark:bg-slate-900");
expect(thicknessClass).toContain("dark:text-slate-100");
expect(thicknessClass).toContain("dark:border-slate-700");
const widthModeSelect = screen.getByLabelText("구분선 길이 모드") as HTMLSelectElement;
const widthModeClass = widthModeSelect.getAttribute("class") ?? "";
expect(widthModeClass).toContain("bg-white");
expect(widthModeClass).toContain("text-slate-900");
expect(widthModeClass).toContain("border-slate-300");
expect(widthModeClass).toContain("dark:bg-slate-900");
expect(widthModeClass).toContain("dark:text-slate-100");
expect(widthModeClass).toContain("dark:border-slate-700");
});
});
+27 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import { EditorCanvas } from "@/app/editor/EditorCanvas";
import { EditorCanvas, EditorCanvasDragPreview } from "@/app/editor/EditorCanvas";
// 에디터 캔버스/페이지 배경색 적용 규칙에 대한 최소 TDD
// - canvasBgColorHex: 실제 캔버스 영역(editor-canvas-inner)의 배경색
@@ -73,4 +73,30 @@ describe("EditorCanvas - 캔버스/페이지 배경색", () => {
// custom 프리셋에서는 canvasWidthPx 값이 그대로 사용된다.
expect(renderWithConfig({ canvasPreset: "custom", canvasWidthPx: 1024 })).toBe("1024px");
});
it("DragOverlay 프리뷰는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const block: Block = {
id: "drag_1",
type: "text",
props: {
text: "드래그 미리보기",
align: "left",
size: "base",
},
} as any;
const { container } = render(<EditorCanvasDragPreview block={block} />);
const overlay = container.querySelector(
"div.pointer-events-none.rounded.border",
) as HTMLElement | null;
expect(overlay).not.toBeNull();
const className = overlay!.getAttribute("class") ?? "";
expect(className).toContain("border-sky-500");
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+152
View File
@@ -214,6 +214,28 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(input.className).toContain("pb-input");
});
it("formInput 에디터 렌더에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_neutral_editor",
type: "formInput",
props: {
label: "폼 입력",
formFieldName: "input_neutral",
inputType: "text",
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_input_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("폼 입력");
expect(label.className).not.toContain("text-slate-");
});
it("formSelect: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -275,6 +297,30 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(select.className).toContain("pb-select");
});
it("formSelect 에디터 렌더에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_select_neutral_editor",
type: "formSelect",
props: {
label: "폼 셀렉트",
formFieldName: "select_neutral",
options: [
{ label: "A", value: "a" },
],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_select_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("폼 셀렉트");
expect(label.className).not.toContain("text-slate-");
});
it("formRadio: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -313,6 +359,87 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(groupContainer.style.borderRadius).toBe("6px");
});
it("formRadio 에디터 그룹 컨테이너에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_neutral_editor",
type: "formRadio",
props: {
formFieldName: "radio-neutral",
groupLabel: "라디오 그룹",
options: [
{ label: "옵션 A", value: "a" },
],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("라디오 그룹");
const groupContainer = label.closest("div") as HTMLElement;
expect(groupContainer.className).not.toContain("text-slate-");
});
it("formRadio 에디터 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_os_editor",
type: "formRadio",
props: {
formFieldName: "radio-os",
groupLabel: "라디오 OS",
options: [{ label: "옵션 A", value: "a" }],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_radio_os_editor";
render(<EditorPage />);
const label = screen.getByText("라디오 OS");
const groupContainer = label.closest("div") as HTMLElement;
const input = groupContainer.querySelector("input[type='radio']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox 에디터 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_os_editor",
type: "formCheckbox",
props: {
formFieldName: "check-os",
groupLabel: "체크 OS",
options: [{ label: "체크 1", value: "c1" }],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_os_editor";
render(<EditorPage />);
const label = screen.getByText("체크 OS");
const groupContainer = label.closest("div") as HTMLElement;
const input = groupContainer.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4,rounded 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox: textColorCustom / fillColorCustom / strokeColorCustom / widthPx / borderRadius 가 에디터 렌더에 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -351,6 +478,31 @@ describe("EditorPage - 폼 블록 스타일", () => {
expect(groupContainer.style.borderRadius).toBe("6px");
});
it("formCheckbox 에디터 그룹 컨테이너에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_neutral_editor",
type: "formCheckbox",
props: {
formFieldName: "checkbox-neutral",
groupLabel: "체크 그룹",
options: [
{ label: "체크 1", value: "c1" },
],
} as any,
},
];
mockState.blocks = blocks;
mockState.selectedBlockId = "form_checkbox_neutral_editor";
render(<EditorPage />);
const label = screen.getByText("체크 그룹");
const groupContainer = label.closest("div") as HTMLElement;
expect(groupContainer.className).not.toContain("text-slate-");
});
it("formRadio: 그룹 타이틀 레이아웃이 inline 이면 에디터에서도 라벨과 필드 컨테이너가 가로 정렬되고 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
+97 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import type { Block, ProjectConfig } from "@/features/editor/state/editorStore";
import EditorPage from "@/app/editor/page";
@@ -97,4 +97,100 @@ describe("EditorPage - 페이지/캔버스 배경", () => {
const canvasOuter = screen.getByTestId("editor-canvas") as HTMLElement;
expect(canvasOuter.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
it("main 요소는 전역 라이트/다크 테마 클래스(bg-slate-100 text-slate-900 dark:bg-slate-950 dark:text-slate-50)를 사용해야 한다", () => {
const { container } = render(<EditorPage />);
const mainEl = container.querySelector("main") as HTMLElement | null;
expect(mainEl).not.toBeNull();
const className = mainEl!.getAttribute("class") ?? "";
expect(className).toContain("bg-slate-100");
expect(className).toContain("text-slate-900");
expect(className).toContain("dark:bg-slate-950");
expect(className).toContain("dark:text-slate-50");
});
it("프로젝트 저장/불러오기 모달 카드는 라이트/다크 테마에 맞는 카드/인풋 크롬을 사용해야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByRole("button", { name: /메뉴/ });
fireEvent.click(menuButton);
const projectMenuItem = screen.getByRole("button", { name: "프로젝트 저장/불러오기" });
fireEvent.click(projectMenuItem);
const heading = screen.getByText("프로젝트 저장 / 불러오기");
const headerDiv = heading.closest("div") as HTMLDivElement | null;
expect(headerDiv).not.toBeNull();
const card = headerDiv!.parentElement as HTMLDivElement | null;
expect(card).not.toBeNull();
const cardClass = card!.getAttribute("class") ?? "";
expect(cardClass).toContain("bg-white");
expect(cardClass).toContain("text-slate-900");
expect(cardClass).toContain("border-slate-200");
expect(cardClass).toContain("dark:bg-slate-900");
expect(cardClass).toContain("dark:text-slate-100");
expect(cardClass).toContain("dark:border-slate-700");
const titleSpan = Array.from(card!.querySelectorAll("span")).find(
(el) => el.textContent === "프로젝트 제목",
);
expect(titleSpan).not.toBeUndefined();
const titleWrapper = (titleSpan as HTMLSpanElement).parentElement as HTMLElement | null;
expect(titleWrapper).not.toBeNull();
const titleInput = titleWrapper!.querySelector("input") as HTMLInputElement | null;
expect(titleInput).not.toBeNull();
const inputClass = titleInput!.getAttribute("class") ?? "";
expect(inputClass).toContain("bg-white");
expect(inputClass).toContain("text-slate-900");
expect(inputClass).toContain("border-slate-300");
expect(inputClass).toContain("dark:bg-slate-900");
expect(inputClass).toContain("dark:text-slate-100");
expect(inputClass).toContain("dark:border-slate-700");
});
it("JSON Export / Import 모달 카드는 라이트/다크 테마에 맞는 카드/textarea 크롬을 사용해야 한다", () => {
render(<EditorPage />);
const menuButton = screen.getByRole("button", { name: /메뉴/ });
fireEvent.click(menuButton);
const jsonMenuItem = screen.getByRole("button", { name: "JSON 내보내기/불러오기" });
fireEvent.click(jsonMenuItem);
const heading = screen.getByText(/JSON Export \/ Import/);
const headerDiv = heading.closest("div") as HTMLDivElement | null;
expect(headerDiv).not.toBeNull();
const card = headerDiv!.parentElement as HTMLDivElement | null;
expect(card).not.toBeNull();
const cardClass = card!.getAttribute("class") ?? "";
expect(cardClass).toContain("bg-white");
expect(cardClass).toContain("text-slate-900");
expect(cardClass).toContain("border-slate-200");
expect(cardClass).toContain("dark:bg-slate-900");
expect(cardClass).toContain("dark:text-slate-100");
expect(cardClass).toContain("dark:border-slate-700");
const jsonLabel = screen.getByText("에디터 상태 JSON");
const jsonWrapper = jsonLabel.parentElement as HTMLElement | null;
expect(jsonWrapper).not.toBeNull();
const textarea = jsonWrapper!.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).not.toBeNull();
const textareaClass = textarea!.getAttribute("class") ?? "";
expect(textareaClass).toContain("bg-white");
expect(textareaClass).toContain("text-slate-900");
expect(textareaClass).toContain("border-slate-300");
expect(textareaClass).toContain("dark:bg-slate-900");
expect(textareaClass).toContain("dark:text-slate-100");
expect(textareaClass).toContain("dark:border-slate-700");
});
});
+118
View File
@@ -87,6 +87,90 @@ vi.mock("next/navigation", () => {
});
describe("EditorPage - 선택 포커스", () => {
it("선택/비선택 블록 셸은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "blk_1",
type: "text",
props: {
text: "첫 번째 블록",
align: "left",
size: "base",
},
} as any,
{
id: "blk_2",
type: "text",
props: {
text: "두 번째 블록",
align: "left",
size: "base",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "blk_2";
render(<EditorPage />);
const allBlocks = screen.getAllByTestId("editor-block") as HTMLElement[];
const selected = allBlocks.find((el) => el.dataset.selected === "true");
const nonSelected = allBlocks.find((el) => el.dataset.selected !== "true");
expect(selected).toBeTruthy();
expect(nonSelected).toBeTruthy();
const selectedClass = selected!.className;
const nonSelectedClass = nonSelected!.className;
// 선택된 블록: 하이라이트 보더 + 라이트/다크 배경/텍스트 듀얼 테마
expect(selectedClass).toContain("border-sky-500");
expect(selectedClass).toContain("bg-sky-50");
expect(selectedClass).toContain("text-slate-900");
expect(selectedClass).toContain("dark:bg-slate-900/80");
expect(selectedClass).toContain("dark:text-slate-100");
// 비선택 블록: 기본 카드 크롬(라이트/다크 듀얼)
expect(nonSelectedClass).toContain("border-slate-200");
expect(nonSelectedClass).toContain("bg-white");
expect(nonSelectedClass).toContain("text-slate-900");
expect(nonSelectedClass).toContain("dark:border-slate-700");
expect(nonSelectedClass).toContain("dark:bg-slate-900");
expect(nonSelectedClass).toContain("dark:text-slate-100");
});
it("블록 드래그 핸들은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "blk_1",
type: "text",
props: {
text: "첫 번째 블록",
align: "left",
size: "base",
},
} as any,
];
mockState.blocks = blocks;
mockState.selectedBlockId = "blk_1";
render(<EditorPage />);
const handle = screen.getByLabelText("블록 드래그 핸들") as HTMLButtonElement;
const className = handle.getAttribute("class") ?? "";
// 라이트 모드: 밝은 버튼 크롬
expect(className).toContain("border-slate-300");
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-500");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:border-slate-700");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-400");
});
it("선택된 블록은 editor-block 에 border-sky-500 / bg-slate-900/80 하이라이트를 가져야 한다", () => {
const blocks: Block[] = [
{
@@ -150,6 +234,40 @@ describe("EditorPage - 선택 포커스", () => {
expect(sectionEl.className).toContain("border-sky-500");
});
it("EditorPage 레이아웃은 main 에 h-screen 컨테이너를 사용하고, 좌/중/우 컬럼이 각각 스크롤되어야 한다", () => {
mockState.blocks = [];
mockState.selectedBlockId = null;
const { container } = render(<EditorPage />);
const main = container.querySelector("main") as HTMLElement;
expect(main).toBeTruthy();
const mainClass = main.getAttribute("class") ?? "";
// 전체 에디터는 뷰포트 높이에 맞춘 h-screen 컨테이너 안에서 동작해야 한다.
expect(mainClass).toContain("h-screen");
expect(mainClass).not.toContain("min-h-screen");
expect(mainClass).toContain("flex");
expect(mainClass).toContain("overflow-hidden");
// 좌측 BlocksSidebar 는 자체 스크롤(overflow-y-auto)을 가져야 한다.
const blocksSidebar = screen.getByTestId("blocks-sidebar") as HTMLElement;
const blocksClass = blocksSidebar.getAttribute("class") ?? "";
expect(blocksClass).toContain("overflow-y-auto");
expect(blocksClass).toContain("pb-scroll");
// 중앙 EditorCanvas 는 자체 스크롤(overflow-auto)을 가져야 한다.
const canvas = screen.getByTestId("editor-canvas") as HTMLElement;
const canvasClass = canvas.getAttribute("class") ?? "";
expect(canvasClass).toContain("overflow-auto");
expect(canvasClass).toContain("pb-scroll");
// 우측 PropertiesSidebar 도 자체 스크롤(overflow-auto)을 가져야 한다.
const propsSidebar = screen.getByTestId("properties-sidebar") as HTMLElement;
const propsClass = propsSidebar.getAttribute("class") ?? "";
expect(propsClass).toContain("overflow-auto");
expect(propsClass).toContain("pb-scroll");
});
it("섹션 자식 블록을 선택해도 섹션 wrapper 가 강조되어야 한다", () => {
const section: Block = {
id: "sec_1",
+1
View File
@@ -169,6 +169,7 @@ describe("EditorPage - 서버 자동 저장", () => {
expect(typeof body.slug).toBe("string");
expect(body.slug).not.toBe("my-landing");
expect(body.slug.startsWith("page-")).toBe(false);
expect(mockState.projectConfig.slug).toBe(body.slug);
});
+180 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
import { FormControllerPanel } from "@/app/editor/forms/FormControllerPanel";
import type { Block, FormBlockProps } from "@/features/editor/state/editorStore";
@@ -222,4 +222,183 @@ describe("FormControllerPanel", () => {
expect(scriptTextarea.value).toContain("params.email || \"\"");
expect(scriptTextarea.value).toContain("new Date()");
});
it("폼 컨트롤러 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-theme", {
submitTarget: "webhook",
} as any);
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock]}
selectedBlockId="form-theme"
updateBlock={updateBlock}
/>,
);
const submitTargetSelect = screen.getByLabelText("전송 대상") as HTMLSelectElement;
const submitTargetClass = submitTargetSelect.getAttribute("class") ?? "";
expect(submitTargetClass).toContain("bg-white");
expect(submitTargetClass).toContain("text-slate-900");
expect(submitTargetClass).toContain("border-slate-300");
expect(submitTargetClass).toContain("dark:bg-slate-900");
expect(submitTargetClass).toContain("dark:text-slate-100");
expect(submitTargetClass).toContain("dark:border-slate-700");
const formWidthModeSelect = screen.getByLabelText("폼 너비 모드") as HTMLSelectElement;
const formWidthModeClass = formWidthModeSelect.getAttribute("class") ?? "";
expect(formWidthModeClass).toContain("bg-white");
expect(formWidthModeClass).toContain("text-slate-900");
expect(formWidthModeClass).toContain("border-slate-300");
expect(formWidthModeClass).toContain("dark:bg-slate-900");
expect(formWidthModeClass).toContain("dark:text-slate-100");
expect(formWidthModeClass).toContain("dark:border-slate-700");
const successInput = screen.getByLabelText("성공 메시지") as HTMLInputElement;
const successClass = successInput.getAttribute("class") ?? "";
expect(successClass).toContain("bg-white");
expect(successClass).toContain("text-slate-900");
expect(successClass).toContain("border-slate-300");
expect(successClass).toContain("dark:bg-slate-900");
expect(successClass).toContain("dark:text-slate-100");
expect(successClass).toContain("dark:border-slate-700");
const errorInput = screen.getByLabelText("에러 메시지") as HTMLInputElement;
const errorClass = errorInput.getAttribute("class") ?? "";
expect(errorClass).toContain("bg-white");
expect(errorClass).toContain("text-slate-900");
expect(errorClass).toContain("border-slate-300");
expect(errorClass).toContain("dark:bg-slate-900");
expect(errorClass).toContain("dark:text-slate-100");
expect(errorClass).toContain("dark:border-slate-700");
const extraParamsTextarea = screen.getByLabelText(/추가 파라미터/) as HTMLTextAreaElement;
const extraParamsClass = extraParamsTextarea.getAttribute("class") ?? "";
expect(extraParamsClass).toContain("bg-white");
expect(extraParamsClass).toContain("text-slate-900");
expect(extraParamsClass).toContain("border-slate-300");
expect(extraParamsClass).toContain("dark:bg-slate-900");
expect(extraParamsClass).toContain("dark:text-slate-100");
expect(extraParamsClass).toContain("dark:border-slate-700");
});
it("Google Sheets 연동 가이드 모달 카드와 textarea 는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-webhook-theme", {
submitTarget: "webhook",
} as any);
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock]}
selectedBlockId="form-webhook-theme"
updateBlock={updateBlock}
/>,
);
const guideButton = screen.getByRole("button", { name: "Google Sheets 연동 가이드" });
fireEvent.click(guideButton);
const heading = screen.getByRole("heading", { name: "Google Sheets 연동 가이드" });
const card = heading.closest("div")?.parentElement as HTMLDivElement | null;
expect(card).not.toBeNull();
const cardClass = card!.getAttribute("class") ?? "";
expect(cardClass).toContain("bg-white");
expect(cardClass).toContain("text-slate-900");
expect(cardClass).toContain("border-slate-200");
expect(cardClass).toContain("dark:bg-slate-900");
expect(cardClass).toContain("dark:text-slate-100");
expect(cardClass).toContain("dark:border-slate-700");
const scriptTextarea = screen.getByDisplayValue(/function doPost/) as HTMLTextAreaElement;
const scriptClass = scriptTextarea.getAttribute("class") ?? "";
expect(scriptClass).toContain("bg-white");
expect(scriptClass).toContain("text-slate-900");
expect(scriptClass).toContain("border-slate-300");
expect(scriptClass).toContain("dark:bg-slate-900");
expect(scriptClass).toContain("dark:text-slate-100");
expect(scriptClass).toContain("dark:border-slate-700");
});
it("폼 필드 매핑 체크박스와 필수 체크박스는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-fields-theme", {
fieldIds: ["input-1"],
requiredFieldIds: ["input-1"],
} as any);
const inputBlock: Block = {
id: "input-1",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, inputBlock]}
selectedBlockId="form-fields-theme"
updateBlock={updateBlock}
/>,
);
const fieldset = screen.getByRole("group", { name: "폼 필드 매핑" });
const checkboxes = within(fieldset).getAllByRole("checkbox");
const includeCheckbox = checkboxes[0] as HTMLInputElement;
const requiredCheckbox = checkboxes[1] as HTMLInputElement;
for (const checkbox of [includeCheckbox, requiredCheckbox]) {
const className = checkbox.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:border-slate-600");
}
});
it("Submit 버튼 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const formBlock = makeFormBlock("form-submit-theme", {
submitButtonId: "btn-1",
} as any);
const buttonBlock: Block = {
id: "btn-1",
type: "button",
props: {
label: "제출하기",
} as any,
} as any;
const updateBlock = vi.fn();
render(
<FormControllerPanel
block={formBlock}
blocks={[formBlock, buttonBlock]}
selectedBlockId="form-submit-theme"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("Submit 버튼") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
});
@@ -104,6 +104,56 @@ describe("FormInputPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 FormInput textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-none", "formInput", {
...baseProps,
textColorCustom: "#ff0000",
} as any);
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-none"
updateBlock={updateBlock}
/>,
);
// 첫 번째 ColorPickerField(필드 텍스트 색상)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다.
const noneButtons = screen.getAllByRole("button", { name: "없음" });
fireEvent.click(noneButtons[0]);
expect(updateBlock).toHaveBeenCalledWith(
"f-input-none",
expect.objectContaining({ textColorCustom: "" }),
);
});
it("FormInput textColorCustom 이 비어 있으면 필드 텍스트 색상 HEX 인풋 값은 빈 문자열이어야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-empty", "formInput", {
...baseProps,
textColorCustom: "",
} as any);
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-empty"
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("필드 텍스트 색상 HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
it("필드 너비 셀렉트 변경 시 updateBlock 이 widthMode 로 호출되어야 한다", () => {
const updateBlock = vi.fn();
@@ -266,6 +316,67 @@ describe("FormInputPropertiesPanel", () => {
expect.objectContaining({ paddingY: 12 }),
);
});
it("FormInput 기본 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormInputBlockProps>("f-input-theme", "formInput", {
...baseProps,
} as any);
render(
<FormInputPropertiesPanel
block={block}
selectedBlockId="f-input-theme"
updateBlock={updateBlock}
/>,
);
const labelTypeSelect = screen.getByLabelText("라벨 타입") as HTMLSelectElement;
const labelTypeClass = labelTypeSelect.getAttribute("class") ?? "";
expect(labelTypeClass).toContain("bg-white");
expect(labelTypeClass).toContain("text-slate-900");
expect(labelTypeClass).toContain("border-slate-300");
expect(labelTypeClass).toContain("dark:bg-slate-900");
expect(labelTypeClass).toContain("dark:text-slate-100");
expect(labelTypeClass).toContain("dark:border-slate-700");
const labelInput = screen.getByLabelText("필드 라벨") as HTMLInputElement;
const labelInputClass = labelInput.getAttribute("class") ?? "";
expect(labelInputClass).toContain("bg-white");
expect(labelInputClass).toContain("text-slate-900");
expect(labelInputClass).toContain("border-slate-300");
expect(labelInputClass).toContain("dark:bg-slate-900");
expect(labelInputClass).toContain("dark:text-slate-100");
expect(labelInputClass).toContain("dark:border-slate-700");
const labelDisplaySelect = screen.getByLabelText("라벨 표시 방식") as HTMLSelectElement;
const labelDisplayClass = labelDisplaySelect.getAttribute("class") ?? "";
expect(labelDisplayClass).toContain("bg-white");
expect(labelDisplayClass).toContain("text-slate-900");
expect(labelDisplayClass).toContain("border-slate-300");
expect(labelDisplayClass).toContain("dark:bg-slate-900");
expect(labelDisplayClass).toContain("dark:text-slate-100");
expect(labelDisplayClass).toContain("dark:border-slate-700");
const fieldTypeSelect = screen.getByLabelText("필드 타입") as HTMLSelectElement;
const fieldTypeClass = fieldTypeSelect.getAttribute("class") ?? "";
expect(fieldTypeClass).toContain("bg-white");
expect(fieldTypeClass).toContain("text-slate-900");
expect(fieldTypeClass).toContain("border-slate-300");
expect(fieldTypeClass).toContain("dark:bg-slate-900");
expect(fieldTypeClass).toContain("dark:text-slate-100");
expect(fieldTypeClass).toContain("dark:border-slate-700");
const placeholderInput = screen.getByLabelText("Placeholder") as HTMLInputElement;
const placeholderClass = placeholderInput.getAttribute("class") ?? "";
expect(placeholderClass).toContain("bg-white");
expect(placeholderClass).toContain("text-slate-900");
expect(placeholderClass).toContain("border-slate-300");
expect(placeholderClass).toContain("dark:bg-slate-900");
expect(placeholderClass).toContain("dark:text-slate-100");
expect(placeholderClass).toContain("dark:border-slate-700");
});
});
describe("FormSelectPropertiesPanel", () => {
@@ -679,6 +790,91 @@ describe("FormSelectPropertiesPanel", () => {
expect.objectContaining({ borderRadius: "full" }),
);
});
it("FormSelect 기본 인풋/셀렉트/옵션 편집 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormSelectBlockProps>("f-select-theme", "formSelect", {
...baseProps,
} as any);
render(
<FormSelectPropertiesPanel
block={block}
selectedBlockId="f-select-theme"
updateBlock={updateBlock}
/>,
);
const labelTypeSelect = screen.getByLabelText("라벨 타입") as HTMLSelectElement;
const labelTypeClass = labelTypeSelect.getAttribute("class") ?? "";
expect(labelTypeClass).toContain("bg-white");
expect(labelTypeClass).toContain("text-slate-900");
expect(labelTypeClass).toContain("border-slate-300");
expect(labelTypeClass).toContain("dark:bg-slate-900");
expect(labelTypeClass).toContain("dark:text-slate-100");
expect(labelTypeClass).toContain("dark:border-slate-700");
const labelInput = screen.getByLabelText("필드 라벨") as HTMLInputElement;
const labelInputClass = labelInput.getAttribute("class") ?? "";
expect(labelInputClass).toContain("bg-white");
expect(labelInputClass).toContain("text-slate-900");
expect(labelInputClass).toContain("border-slate-300");
expect(labelInputClass).toContain("dark:bg-slate-900");
expect(labelInputClass).toContain("dark:text-slate-100");
expect(labelInputClass).toContain("dark:border-slate-700");
const labelDisplaySelect = screen.getByLabelText("라벨 표시 방식") as HTMLSelectElement;
const labelDisplayClass = labelDisplaySelect.getAttribute("class") ?? "";
expect(labelDisplayClass).toContain("bg-white");
expect(labelDisplayClass).toContain("text-slate-900");
expect(labelDisplayClass).toContain("border-slate-300");
expect(labelDisplayClass).toContain("dark:bg-slate-900");
expect(labelDisplayClass).toContain("dark:text-slate-100");
expect(labelDisplayClass).toContain("dark:border-slate-700");
const widthModeSelect = screen.getByLabelText("필드 너비") as HTMLSelectElement;
const widthModeClass = widthModeSelect.getAttribute("class") ?? "";
expect(widthModeClass).toContain("bg-white");
expect(widthModeClass).toContain("text-slate-900");
expect(widthModeClass).toContain("border-slate-300");
expect(widthModeClass).toContain("dark:bg-slate-900");
expect(widthModeClass).toContain("dark:text-slate-100");
expect(widthModeClass).toContain("dark:border-slate-700");
const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement;
const optionLabelClass = optionLabelInput.getAttribute("class") ?? "";
expect(optionLabelClass).toContain("bg-white");
expect(optionLabelClass).toContain("text-slate-900");
expect(optionLabelClass).toContain("border-slate-300");
expect(optionLabelClass).toContain("dark:bg-slate-900");
expect(optionLabelClass).toContain("dark:text-slate-100");
expect(optionLabelClass).toContain("dark:border-slate-700");
});
it("FormSelect 옵션 행은 라벨/값 인풋과 삭제 버튼을 위한 3열 그리드 레이아웃 클래스를 사용해야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormSelectBlockProps>("f-select-option-layout", "formSelect", {
...baseProps,
} as any);
render(
<FormSelectPropertiesPanel
block={block}
selectedBlockId="f-select-option-layout"
updateBlock={updateBlock}
/>,
);
const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement;
const rowDiv = optionLabelInput.closest("div") as HTMLDivElement | null;
expect(rowDiv).not.toBeNull();
const rowClass = rowDiv!.getAttribute("class") ?? "";
expect(rowClass).toContain("grid");
expect(rowClass).toContain("grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]");
});
});
describe("FormCheckboxPropertiesPanel", () => {
@@ -1042,6 +1238,32 @@ describe("FormCheckboxPropertiesPanel", () => {
);
});
it("FormCheckbox 필드 너비 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormCheckboxBlockProps>("f-check-theme-width", "formCheckbox", {
...baseProps,
} as any);
render(
<FormCheckboxPropertiesPanel
block={block}
selectedBlockId="f-check-theme-width"
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("필드 너비") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
it("폼 라디오 텍스트/줄간격/자간 px 컨트롤이 fontSizeCustom/lineHeightCustom/letterSpacingCustom 으로 저장되어야 한다", () => {
const updateBlock = vi.fn();
@@ -1452,4 +1674,65 @@ describe("FormCheckboxPropertiesPanel", () => {
expect.objectContaining({ borderRadius: "full" }),
);
});
it("FormRadio 기본 인풋/셀렉트/옵션 편집 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
const block = makeBlock<FormRadioBlockProps>("f-radio-theme", "formRadio", {
...baseProps,
} as any);
render(
<FormRadioPropertiesPanel
block={block}
selectedBlockId="f-radio-theme"
updateBlock={updateBlock}
/>,
);
const groupTitleInput = screen.getByLabelText("그룹 타이틀") as HTMLInputElement;
const groupTitleClass = groupTitleInput.getAttribute("class") ?? "";
expect(groupTitleClass).toContain("bg-white");
expect(groupTitleClass).toContain("text-slate-900");
expect(groupTitleClass).toContain("border-slate-300");
expect(groupTitleClass).toContain("dark:bg-slate-900");
expect(groupTitleClass).toContain("dark:text-slate-100");
expect(groupTitleClass).toContain("dark:border-slate-700");
const groupDisplaySelect = screen.getByLabelText("그룹 타이틀 표시 방식") as HTMLSelectElement;
const groupDisplayClass = groupDisplaySelect.getAttribute("class") ?? "";
expect(groupDisplayClass).toContain("bg-white");
expect(groupDisplayClass).toContain("text-slate-900");
expect(groupDisplayClass).toContain("border-slate-300");
expect(groupDisplayClass).toContain("dark:bg-slate-900");
expect(groupDisplayClass).toContain("dark:text-slate-100");
expect(groupDisplayClass).toContain("dark:border-slate-700");
const optionLayoutSelect = screen.getByLabelText("옵션 레이아웃") as HTMLSelectElement;
const optionLayoutClass = optionLayoutSelect.getAttribute("class") ?? "";
expect(optionLayoutClass).toContain("bg-white");
expect(optionLayoutClass).toContain("text-slate-900");
expect(optionLayoutClass).toContain("border-slate-300");
expect(optionLayoutClass).toContain("dark:bg-slate-900");
expect(optionLayoutClass).toContain("dark:text-slate-100");
expect(optionLayoutClass).toContain("dark:border-slate-700");
const formFieldNameInput = screen.getByLabelText("전송 키") as HTMLInputElement;
const formFieldNameClass = formFieldNameInput.getAttribute("class") ?? "";
expect(formFieldNameClass).toContain("bg-white");
expect(formFieldNameClass).toContain("text-slate-900");
expect(formFieldNameClass).toContain("border-slate-300");
expect(formFieldNameClass).toContain("dark:bg-slate-900");
expect(formFieldNameClass).toContain("dark:text-slate-100");
expect(formFieldNameClass).toContain("dark:border-slate-700");
const optionLabelInput = screen.getByPlaceholderText("라벨") as HTMLInputElement;
const optionLabelClass = optionLabelInput.getAttribute("class") ?? "";
expect(optionLabelClass).toContain("bg-white");
expect(optionLabelClass).toContain("text-slate-900");
expect(optionLabelClass).toContain("border-slate-300");
expect(optionLabelClass).toContain("dark:bg-slate-900");
expect(optionLabelClass).toContain("dark:text-slate-100");
expect(optionLabelClass).toContain("dark:border-slate-700");
});
});
+39
View File
@@ -192,4 +192,43 @@ describe("ImagePropertiesPanel", () => {
expect.objectContaining({ borderRadius: "sm", borderRadiusPx: 24 }),
);
});
it("ImagePropertiesPanel 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ImagePropertiesPanel
imageProps={baseProps as any}
selectedBlockId="image-theme-1"
updateBlock={updateBlock}
/>,
);
const sourceSelect = screen.getByLabelText("이미지 소스") as HTMLSelectElement;
const sourceClass = sourceSelect.getAttribute("class") ?? "";
expect(sourceClass).toContain("bg-white");
expect(sourceClass).toContain("text-slate-900");
expect(sourceClass).toContain("border-slate-300");
expect(sourceClass).toContain("dark:bg-slate-900");
expect(sourceClass).toContain("dark:text-slate-100");
expect(sourceClass).toContain("dark:border-slate-700");
const urlInput = screen.getByLabelText("이미지 URL") as HTMLInputElement;
const urlClass = urlInput.getAttribute("class") ?? "";
expect(urlClass).toContain("bg-white");
expect(urlClass).toContain("text-slate-900");
expect(urlClass).toContain("border-slate-300");
expect(urlClass).toContain("dark:bg-slate-900");
expect(urlClass).toContain("dark:text-slate-100");
expect(urlClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("이미지 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
});
});
+111
View File
@@ -121,6 +121,48 @@ describe("ListPropertiesPanel", () => {
);
});
it("텍스트 색상 팔레트에서 \"없음\" 을 선택하면 textColorCustom 이 빈 문자열로 업데이트되어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, textColorCustom: "#ff0000" }}
selectedBlockId="list-text-none"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
// 첫 번째 ColorPickerField(텍스트 색상)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 버튼 role 기준으로 선택한다 (불릿 스타일 select 의 option "없음" 과 구분).
const noneButtons = screen.getAllByRole("button", { name: "없음" });
fireEvent.click(noneButtons[0]);
expect(updateBlock).toHaveBeenCalledWith(
"list-text-none",
expect.objectContaining({ textColorCustom: "" }),
);
});
it("textColorCustom 이 비어 있으면 텍스트 색상 HEX 인풋 값은 빈 문자열이어야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={{ ...baseProps, textColorCustom: "" }}
selectedBlockId="list-text-empty"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const hexInput = screen.getByLabelText("리스트 텍스트 색상 HEX") as HTMLInputElement;
expect(hexInput.value).toBe("");
});
it("불릿 스타일 셀렉트 변경 시 bulletStyle 과 ordered 가 함께 업데이트되어야 한다 (decimal)", () => {
const updateBlock = vi.fn();
@@ -183,4 +225,73 @@ describe("ListPropertiesPanel", () => {
expect.objectContaining({ gapYPx: 16 }),
);
});
it("리스트 정렬 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={baseProps}
selectedBlockId="list-align-theme"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("리스트 정렬") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
it("리스트 불릿 스타일 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={baseProps}
selectedBlockId="list-bullet-theme"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const select = screen.getByLabelText("리스트 불릿 스타일") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
it("리스트 아이템 textarea 는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<ListPropertiesPanel
listProps={baseProps}
selectedBlockId="list-theme"
selectedListItemId={null}
updateBlock={updateBlock}
/>,
);
const textarea = screen.getByLabelText("리스트 아이템들") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+84 -4
View File
@@ -24,7 +24,7 @@ describe("LoginPage", () => {
vi.clearAllMocks();
});
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
it("올바른 이메일/비밀번호로 로그인하면 /api/auth/login 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -82,7 +82,7 @@ describe("LoginPage", () => {
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
@@ -129,7 +129,7 @@ describe("LoginPage", () => {
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /login 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
it("이미 로그인된 상태에서 /login 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
@@ -144,7 +144,87 @@ describe("LoginPage", () => {
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
it("로그인 페이지는 전역 라이트/다크 테마 배경 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const main = await screen.findByRole("main");
const className = (main as HTMLElement).className;
expect(className).toContain("bg-slate-100");
expect(className).toContain("dark:bg-slate-950");
});
it("비밀번호 입력 필드는 이메일 입력과 동일한 라이트/다크 테마 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const emailClass = emailInput.className;
const passwordClass = passwordInput.className;
expect(emailClass).toContain("border-slate-300");
expect(emailClass).toContain("bg-white");
expect(emailClass).toContain("text-slate-900");
expect(emailClass).toContain("dark:border-slate-700");
expect(emailClass).toContain("dark:bg-slate-950");
expect(emailClass).toContain("dark:text-slate-50");
expect(passwordClass).toBe(emailClass);
});
it("로그인 페이지에도 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<LoginPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
});
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { NumericPropertyControl } from "@/features/editor/components/NumericPropertyControl";
describe("NumericPropertyControl - 테마", () => {
it("프리셋 셀렉트는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<NumericPropertyControl
label="테스트 숫자"
unitLabel="px"
value={16}
min={0}
max={100}
step={1}
presets={[
{ id: "small", label: "작게", value: 12 },
{ id: "medium", label: "보통", value: 16 },
]}
onChangeValue={() => {}}
/>,
);
const select = screen.getByLabelText("테스트 숫자 프리셋") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+12
View File
@@ -134,4 +134,16 @@ describe("PreviewPage - canvasPreset / canvasWidthPx", () => {
expect(inner.style.backgroundColor).toBe("rgb(17, 17, 17)");
expect(main.style.backgroundColor).toBe("rgb(34, 34, 34)");
});
it("bodyBgColorHex 가 빈 문자열이면 PreviewPage main 에 background-color 가 들어가지 않아야 한다", () => {
mockState.projectConfig = {
...(mockState.projectConfig as ProjectConfig),
bodyBgColorHex: "",
} as ProjectConfig;
const { container } = render(<PreviewPage />);
const main = container.querySelector("main") as HTMLElement;
// 명시적으로 빈 문자열을 지정한 경우에는 PreviewPage 가 어떤 기본 배경색도 강제로 설정하지 않아야 한다.
expect(main.style.backgroundColor).toBe("");
});
});
@@ -122,5 +122,46 @@ describe("ProjectPropertiesPanel", () => {
fireEvent.change(input, { target: { value: "#654321" } });
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "#654321" });
});
it("캔버스 배경색 팔레트에서 \"없음\" 을 선택하면 canvasBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
// 첫 번째 ColorPickerField(캔버스 배경색)의 팔레트 버튼을 연다.
const paletteButtons = screen.getAllByText("색상 팔레트");
fireEvent.click(paletteButtons[0].closest("button") as HTMLButtonElement);
// 팔레트에서 "없음" 항목을 선택한다.
const noneButton = screen.getByText("없음");
fireEvent.click(noneButton);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ canvasBgColorHex: "" });
});
it("페이지 배경색 팔레트에서 \"없음\" 을 선택하면 bodyBgColorHex 가 빈 문자열로 업데이트되어야 한다", () => {
render(<ProjectPropertiesPanel />);
// 두 번째 ColorPickerField(페이지 배경색)의 팔레트 버튼은 기본 선택 팔레트 라벨("어두운 텍스트")를 기준으로 찾는다.
const darkLabel = screen.getByText("어두운 텍스트");
fireEvent.click(darkLabel.closest("button") as HTMLButtonElement);
const noneButton = screen.getByText("없음");
fireEvent.click(noneButton);
expect(mockState.updateProjectConfig).toHaveBeenCalledWith({ bodyBgColorHex: "" });
});
it("프로젝트 제목 입력은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(<ProjectPropertiesPanel />);
const input = screen.getByLabelText("프로젝트 제목") as HTMLInputElement;
const className = input.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
+91 -3
View File
@@ -135,7 +135,7 @@ describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
expect(errorText).toBeTruthy();
});
it("상단에 '프로젝트 목록' 링크가 있고 클릭 시 /projects 로 이동해야 한다", async () => {
it("상단에 '프로젝트 목록' 링크가 있고 href 가 /projects 이어야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
@@ -151,11 +151,99 @@ describe("ProjectSubmissionsPage - 폼 제출 내역", () => {
const backLink = await screen.findByRole("link", { name: "프로젝트 목록" });
expect(backLink).toBeTruthy();
expect(backLink.getAttribute("href")).toBe("/projects");
});
backLink.click();
it("상단 헤더에 공통 GNB와 테마 전환 버튼이 노출되고 테마 토글이 html 요소의 dark 클래스를 토글해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectSubmissionsPage />);
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(dashboardLink.getAttribute("href")).toBe("/dashboard");
expect(projectsLink.getAttribute("href")).toBe("/projects");
expect(submissionsLink.getAttribute("href")).toBe("/projects/submissions");
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
it("프로젝트별 제출 내역 테이블은 라이트 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => {
useParamsMock.mockReturnValue({ slug: "test-project" });
const submissions = [
{
id: "1",
createdAt: "2025-01-01T12:00:00.000Z",
projectSlug: "test-project",
payload: {
name: "홍길동",
email: "user@example.com",
phone: "010-1234-5678",
birthdate: "1990-01-01",
message: "안녕하세요",
},
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(submissions), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
const { container } = render(<ProjectSubmissionsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const headerRow = container.querySelector("thead tr") as HTMLElement | null;
expect(headerRow).not.toBeNull();
const headerClass = headerRow!.className;
expect(headerClass).toContain("border-slate-200");
expect(headerClass).toContain("text-slate-600");
expect(headerClass).toContain("dark:border-slate-800");
expect(headerClass).toContain("dark:text-slate-400");
const nameCellNode = await screen.findByText("홍길동");
const nameCell = nameCellNode.closest("td") as HTMLElement | null;
expect(nameCell).not.toBeNull();
const nameClass = nameCell!.className;
expect(nameClass).toContain("text-slate-900");
expect(nameClass).toContain("dark:text-slate-100");
});
});
+263 -1
View File
@@ -112,6 +112,227 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(link.closest("a")?.getAttribute("href")).toBe("/projects/submissions");
});
it("헤더에 대시보드 페이지(/dashboard)로 이동하는 링크가 있어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const link = screen.getByText("대시보드");
expect(link).toBeTruthy();
expect(link.closest("a")?.getAttribute("href")).toBe("/dashboard");
});
it("프로젝트 목록 테이블은 라이트/다크 테마에서 읽기 쉬운 텍스트 색상을 사용해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const titleCellNode = await screen.findByText("테스트 프로젝트 A");
const titleCell = titleCellNode.closest("td") as HTMLElement | null;
expect(titleCell).not.toBeNull();
const titleClassName = titleCell!.className;
expect(titleClassName).toContain("text-slate-900");
expect(titleClassName).toContain("dark:text-slate-100");
const slugCellNode = screen.getByText("test-project-a");
const slugCell = slugCellNode.closest("td") as HTMLElement | null;
expect(slugCell).not.toBeNull();
const slugClassName = slugCell!.className;
expect(slugClassName).toContain("text-slate-600");
expect(slugClassName).toContain("dark:text-slate-300");
});
it("상단 툴바/액션 링크/페이지네이션은 라이트/다크 테마 모두에서 읽기 쉬운 색상 클래스를 사용해야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const toolbar = await screen.findByTestId("projects-toolbar");
const toolbarClass = toolbar.className;
expect(toolbarClass).toContain("text-slate-600");
expect(toolbarClass).toContain("dark:text-slate-300");
const editLink = screen.getByText("편집").closest("a") as HTMLElement | null;
expect(editLink).not.toBeNull();
const editClass = editLink!.className;
expect(editClass).toContain("text-sky-600");
expect(editClass).toContain("dark:text-sky-300");
const previewLink = screen.getByText("미리보기").closest("a") as HTMLElement | null;
expect(previewLink).not.toBeNull();
const previewClass = previewLink!.className;
expect(previewClass).toContain("text-slate-600");
expect(previewClass).toContain("dark:text-slate-300");
const submissionsLink = screen.getByText("폼 제출 내역").closest("a") as HTMLElement | null;
expect(submissionsLink).not.toBeNull();
const submissionsClass = submissionsLink!.className;
expect(submissionsClass).toContain("text-emerald-600");
expect(submissionsClass).toContain("dark:text-emerald-300");
const deleteButton = screen.getByText("삭제").closest("button") as HTMLElement | null;
expect(deleteButton).not.toBeNull();
const deleteClass = deleteButton!.className;
expect(deleteClass).toContain("text-red-600");
expect(deleteClass).toContain("dark:text-red-300");
const prevButton = screen.getByRole("button", { name: "이전" });
const prevClass = prevButton.className;
expect(prevClass).toContain("border-slate-300");
expect(prevClass).toContain("text-slate-700");
expect(prevClass).toContain("dark:border-slate-700");
expect(prevClass).toContain("dark:text-slate-300");
});
it("GNB에서 현재 페이지인 '프로젝트 목록' 탭에 aria-current=\"page\"가 설정되어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const dashboardLink = await screen.findByRole("link", { name: "대시보드" });
const projectsLink = await screen.findByRole("link", { name: "프로젝트 목록" });
const submissionsLink = await screen.findByRole("link", { name: "전체 제출 내역" });
expect(projectsLink.closest("a")?.getAttribute("href")).toBe("/projects");
expect(projectsLink.closest("a")?.getAttribute("aria-current")).toBe("page");
expect(dashboardLink.closest("a")?.getAttribute("aria-current")).toBeNull();
expect(submissionsLink.closest("a")?.getAttribute("aria-current")).toBeNull();
});
it("헤더에 테마 전환 버튼이 있고 클릭 시 html 요소의 dark 클래스가 토글되어야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
const html = document.documentElement;
html.classList.remove("dark");
const themeToggleButton = await screen.findByRole("button", { name: "테마 전환" });
expect(html.classList.contains("dark")).toBe(false);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(true);
themeToggleButton.click();
expect(html.classList.contains("dark")).toBe(false);
});
it("각 프로젝트 행에는 폼 제출 내역 페이지(/projects/[slug]/submissions)로 이동하는 링크가 있어야 한다", async () => {
const projects = [
{
@@ -227,6 +448,44 @@ describe("ProjectsPage - 프로젝트 목록", () => {
confirmSpy.mockRestore();
});
it("프로젝트 목록 상단 툴바에 '새 프로젝트 만들기' 버튼과 '선택 삭제' 버튼이 함께 보여야 한다", async () => {
const projects = [
{
id: "1",
title: "테스트 프로젝트 A",
slug: "test-project-a",
status: "DRAFT",
createdAt: "2025-01-01T00:00:00.000Z",
updatedAt: "2025-01-01T00:00:00.000Z",
},
{
id: "2",
title: "테스트 프로젝트 B",
slug: "test-project-b",
status: "PUBLISHED",
createdAt: "2025-01-02T00:00:00.000Z",
updatedAt: "2025-01-02T00:00:00.000Z",
},
];
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify(projects), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
render(<ProjectsPage />);
// 상단 툴바에서 선택 삭제와 새 프로젝트 만들기 버튼이 함께 보여야 한다.
const toolbar = await screen.findByTestId("projects-toolbar");
expect(toolbar.querySelector("button[type='button']")?.textContent).toContain("선택 삭제");
expect(toolbar.querySelector("a[href='/editor?new=1']")?.textContent).toContain("새 프로젝트 만들기");
});
it("체크박스로 여러 프로젝트를 선택한 뒤 '선택 삭제' 버튼으로 일괄 삭제할 수 있어야 한다", async () => {
const projects = [
{
@@ -336,7 +595,7 @@ describe("ProjectsPage - 프로젝트 목록", () => {
});
});
it("헤더의 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
it("헤더의 메뉴 안 '로그아웃' 버튼을 클릭하면 /api/auth/logout 으로 POST 요청을 보내고 /login 으로 이동해야 한다", async () => {
const projects = [
{
id: "1",
@@ -376,6 +635,9 @@ describe("ProjectsPage - 프로젝트 목록", () => {
expect(fetchMock).toHaveBeenCalled();
});
const menuButton = await screen.findByRole("button", { name: "메뉴" });
fireEvent.click(menuButton);
const logoutButton = await screen.findByRole("button", { name: "로그아웃" });
fireEvent.click(logoutButton);
+85
View File
@@ -56,6 +56,30 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
expect(screen.getByText(/제목과 본문 텍스트를 입력할 때 사용하는 기본 블록입니다/)).toBeDefined();
});
it("HELP 모달은 라이트/다크 테마에 맞는 카드 배경/테두리/텍스트 클래스를 사용해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const helpButton = screen.getByRole("button", { name: "HELP" });
fireEvent.click(helpButton);
// 모달 카드 컨테이너 (제목 h3 → 헤더 div → 카드 div 순으로 감싸져 있으므로 parentElement.parentElement 사용)
const heading = screen.getByText("텍스트 블록 튜토리얼");
const modalCard = heading.parentElement?.parentElement as HTMLElement | null;
expect(modalCard).not.toBeNull();
const className = modalCard!.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트 + 연한 테두리
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-200");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:border-slate-700");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("모달의 닫기 버튼을 클릭하면 튜토리얼 모달이 닫혀야 한다", () => {
const textBlock = createTextBlock();
@@ -69,4 +93,65 @@ describe("PropertiesSidebar HELP 튜토리얼 모달", () => {
expect(screen.queryByText("텍스트 블록 튜토리얼")).toBeNull();
});
it("텍스트 블록이 선택된 상태에서 상단 액션 버튼들은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const deleteButton = screen.getByRole("button", { name: "블록 삭제" });
const duplicateButton = screen.getByRole("button", { name: "블록 복제" });
const helpButton = screen.getByRole("button", { name: "HELP" });
for (const btn of [deleteButton, duplicateButton, helpButton]) {
const className = btn.getAttribute("class") ?? "";
// 라이트 모드: 밝은 배경 + 어두운 텍스트
expect(className).toContain("bg-slate-50");
expect(className).toContain("text-slate-900");
// 다크 모드: 기존 다크 톤 유지
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
}
});
it("텍스트 블록이 선택된 상태에서 내용 편집 textarea는 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
const textBlock = createTextBlock();
render(<PropertiesSidebar {...defaultProps([textBlock], textBlock.id)} />);
const textarea = screen.getByLabelText("선택한 텍스트 블록 내용") as HTMLTextAreaElement;
const className = textarea.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
it("속성 패널 루트는 라이트/다크 테마에 맞는 배경/테두리 클래스를 사용해야 한다", () => {
const props = defaultProps([], null);
render(<PropertiesSidebar {...props} />);
const aside = screen.getByTestId("properties-sidebar") as HTMLElement;
const className = aside.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("border-slate-200");
expect(className).toContain("dark:border-slate-800");
expect(className).toContain("dark:bg-slate-950/40");
});
it("속성 패널 루트는 pb-scroll 커스텀 스크롤바 클래스를 사용해 에디터용 스크롤바 테마를 적용해야 한다", () => {
const props = defaultProps([], null);
render(<PropertiesSidebar {...props} />);
const aside = screen.getByTestId("properties-sidebar") as HTMLElement;
const className = aside.getAttribute("class") ?? "";
expect(className).toContain("pb-scroll");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PropertySliderField } from "@/features/editor/components/PropertySliderField";
describe("PropertySliderField - 테마", () => {
it("텍스트 입력은 라이트/다크 테마에 맞는 배경/텍스트 클래스를 사용해야 한다", () => {
render(
<PropertySliderField
label="테스트 슬라이더"
ariaLabelSlider="테스트 슬라이더"
ariaLabelInput="테스트 값 입력"
value={10}
min={0}
max={100}
step={1}
onChange={() => {}}
/>,
);
const input = screen.getByLabelText("테스트 값 입력") as HTMLInputElement;
const className = input.getAttribute("class") ?? "";
// 라이트 모드: 흰 배경 + 어두운 텍스트
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
// 다크 모드: 다크 배경 + 밝은 텍스트
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
});
});
@@ -18,6 +18,7 @@ describe("PublicPageRenderer - 배경색 위임", () => {
const className = root!.getAttribute("class") ?? "";
expect(className).not.toContain("bg-slate-950");
expect(className).not.toContain("text-slate-50");
});
it("루트 텍스트/버튼 블록 영역은 고정 bg-slate-950 배경 대신 캔버스 배경을 그대로 보여야 한다", () => {
@@ -111,6 +111,24 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(input!.style.letterSpacing).toBe("0.125em");
});
it("formInput 기본 래퍼 클래스에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_neutral_wrapper",
type: "formInput",
props: {
label: "이름",
formFieldName: "name",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const wrapper = getByTestId("preview-form-input-wrapper") as HTMLElement;
// 기본 래퍼에는 text-slate-* 색상 클래스를 사용하지 않고, 텍스트 색상은 상위/브라우저 기본값을 따른다.
expect(wrapper.className).not.toContain("text-slate-");
});
it("formInput 블록에서 라벨 표시 방식이 hidden 이면 sr-only 클래스로 시각적으로 숨겨야 한다", () => {
const blocks: Block[] = [
{
@@ -201,6 +219,27 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(field.style.getPropertyValue("--pb-input-padding-y")).toBe("1rem");
});
it("formInput 플로팅 라벨에서 strokeColorCustom 은 wrapper 의 --pb-input-border-color CSS 변수로 전달되어야 한다", () => {
const blocks: Block[] = [
{
id: "form_input_floating_border_color",
type: "formInput",
props: {
label: "플로팅 보더",
formFieldName: "floating_border",
labelDisplay: "floating",
strokeColorCustom: "#ff0000",
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const field = getByTestId("preview-form-input-wrapper") as HTMLDivElement;
// CSS 변수는 원본 hex 문자열 그대로 설정된다.
expect(field.style.getPropertyValue("--pb-input-border-color")).toBe("#ff0000");
});
it("formInput 플로팅 라벨에서 label 과 placeholder 가 같으면 placeholder 텍스트를 인풋 안에 표시하지 않아야 한다", () => {
const blocks: Block[] = [
{
@@ -338,94 +377,155 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const firstLabel = getFirstLabel(group);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const firstLabel = getFirstLabel(group);
// width: 320px / 16 = 20em
expect(group.style.width).toBe("20em");
// 옵션 간 간격: 16px -> 1em
const optionsContainer = group.querySelector('[data-testid="preview-form-checkbox-options"]') as HTMLElement | null;
expect(optionsContainer).not.toBeNull();
expect(optionsContainer!.style.rowGap).toBe("1em");
// width: 320px / 16 = 20em
expect(group.style.width).toBe("20em");
// 옵션 간 간격: 16px -> 1em
const optionsContainer = group.querySelector('[data-testid="preview-form-checkbox-options"]') as HTMLElement | null;
expect(optionsContainer).not.toBeNull();
expect(optionsContainer!.style.rowGap).toBe("1em");
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
expect(firstLabel.style.paddingInline).toBe("0.5em");
expect(firstLabel.style.paddingBlock).toBe("0.25em");
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
expect(firstLabel.style.borderRadius).toBe("6px");
});
// 옵션 컨테이너 스타일: padding, 배경, 보더, 둥글기
expect(firstLabel.style.paddingInline).toBe("0.5em");
expect(firstLabel.style.paddingBlock).toBe("0.25em");
expect(firstLabel.style.backgroundColor).toBe("rgb(18, 52, 86)");
expect(firstLabel.style.borderColor).toBe("rgb(255, 0, 0)");
expect(firstLabel.style.borderRadius).toBe("6px");
});
it("formRadio 블록의 옵션 input 은 type/name/value 및 FormBlock.requiredFieldIds 기반 required 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "form_ctrl_radio",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
requiredFieldIds: ["radio_required"],
} as any,
},
{
id: "radio_required",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
},
];
it("formCheckbox 그룹 래퍼 클래스에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_neutral_wrapper",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [{ label: "옵션 1", value: "opt1" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
// 체크박스 그룹 래퍼에는 text-slate-* 색상 클래스를 사용하지 않고, 텍스트 색상은 토큰/브라우저 기본값을 따른다.
expect(group.className).not.toContain("text-slate-");
});
const group = getByTestId("preview-form-radio-group") as HTMLElement;
const inputs = group.querySelectorAll("input[type='radio']");
expect(inputs.length).toBe(2);
it("formRadio 그룹 래퍼 클래스에는 text-slate-* 색상 클래스가 없어야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_neutral_wrapper",
type: "formRadio",
props: {
groupLabel: "라디오 그룹",
formFieldName: "radio_group",
options: [{ label: "옵션 A", value: "a" }],
} as any,
},
];
const first = inputs[0] as HTMLInputElement;
const second = inputs[1] as HTMLInputElement;
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
// 라디오 그룹 래퍼에는 text-slate-* 색상 클래스를 사용하지 않고, 텍스트 색상은 토큰/브라우저 기본값을 따른다.
expect(group.className).not.toContain("text-slate-");
});
expect(first.type).toBe("radio");
expect(first.name).toBe("plan");
expect(first.value).toBe("a");
expect(first.required).toBe(true);
it("formRadio 블록의 옵션 input 은 type/name/value 및 FormBlock.requiredFieldIds 기반 required 를 가져야 한다", () => {
const blocks: Block[] = [
{
id: "form_ctrl_radio",
type: "form",
props: {
kind: "contact",
submitTarget: "internal",
requiredFieldIds: ["radio_required"],
} as any,
},
{
id: "radio_required",
type: "formRadio",
props: {
groupLabel: "플랜",
formFieldName: "plan",
options: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
} as any,
},
];
expect(second.type).toBe("radio");
expect(second.name).toBe("plan");
expect(second.value).toBe("b");
expect(second.required).toBe(false);
});
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
it("formCheckbox 블록의 각 옵션 라벨은 builder.css 의 pb-form-option 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_pb_option_class",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
} as any,
},
];
const group = getByTestId("preview-form-radio-group") as HTMLElement;
const inputs = group.querySelectorAll("input[type='radio']");
expect(inputs.length).toBe(2);
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const first = inputs[0] as HTMLInputElement;
const second = inputs[1] as HTMLInputElement;
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const optionLabels = group.querySelectorAll("label");
expect(optionLabels.length).toBeGreaterThan(0);
optionLabels.forEach((label) => {
expect((label as HTMLLabelElement).className).toContain("pb-form-option");
});
});
expect(first.type).toBe("radio");
expect(first.name).toBe("plan");
expect(first.value).toBe("a");
expect(first.required).toBe(true);
expect(second.type).toBe("radio");
expect(second.name).toBe("plan");
expect(second.value).toBe("b");
expect(second.required).toBe(false);
});
it("formCheckbox 블록의 각 옵션 라벨은 builder.css 의 pb-form-option 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_pb_option_class",
type: "formCheckbox",
props: {
groupLabel: "옵션들",
formFieldName: "features",
options: [
{ label: "옵션 1", value: "opt1" },
{ label: "옵션 2", value: "opt2" },
],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const optionLabels = group.querySelectorAll("label");
expect(optionLabels.length).toBeGreaterThan(0);
optionLabels.forEach((label) => {
expect((label as HTMLLabelElement).className).toContain("pb-form-option");
});
});
it("formRadio 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_radio_os_default",
type: "formRadio",
props: {
groupLabel: "라디오",
formFieldName: "radio_os",
options: [{ label: "옵션 A", value: "a" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-radio-group") as HTMLElement;
const input = group.querySelector("input[type='radio']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox 블록에서 optionLayout 이 stacked 이면 옵션 컨테이너가 pb-form-options--stacked 클래스를 사용해야 한다", () => {
const blocks: Block[] = [
@@ -547,6 +647,52 @@ describe("PublicPageRenderer - 폼 필드 스타일", () => {
expect(second.required).toBe(false);
});
it("formCheckbox 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_os_default",
type: "formCheckbox",
props: {
groupLabel: "체크",
formFieldName: "features_os",
options: [{ label: "옵션 1", value: "opt1" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const input = group.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4,rounded 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formCheckbox 프리뷰의 옵션 input 은 OS 기본 스타일을 사용하기 위해 색상 관련 Tailwind 클래스(bg-/text-/border-)를 사용하지 않아야 한다", () => {
const blocks: Block[] = [
{
id: "form_checkbox_os_default",
type: "formCheckbox",
props: {
groupLabel: "체크",
formFieldName: "features_os",
options: [{ label: "옵션 1", value: "opt1" }],
} as any,
},
];
const { getByTestId } = render(<PublicPageRenderer blocks={blocks} />);
const group = getByTestId("preview-form-checkbox-group") as HTMLElement;
const input = group.querySelector("input[type='checkbox']") as HTMLInputElement | null;
expect(input).not.toBeNull();
// 크기/모양(h-4,w-4,rounded 등)만 유지하고, 색상 관련 Tailwind 유틸은 사용하지 않는다.
expect(input!.className).not.toContain("bg-");
expect(input!.className).not.toContain("text-");
expect(input!.className).not.toContain("border-");
});
it("formRadio 블록에서 그룹 타이틀 레이아웃이 inline 이면 그룹 컨테이너가 가로 정렬되고 세로 중앙 정렬 및 columnGap 이 labelGapPx 로 반영되어야 한다", () => {
const blocks: Block[] = [
{
@@ -101,4 +101,47 @@ describe("PublicPageRenderer - 섹션/루트 레이아웃", () => {
const innerContainer = rootSection.querySelector(".pb-root-inner");
expect(innerContainer).not.toBeNull();
});
it("블록 배열 순서를 따라 루트 블록과 섹션 블록이 섞여 렌더되어야 한다", () => {
const rootBeforeProps: TextBlockProps = {
text: "루트1",
align: "left",
size: "base",
} as TextBlockProps;
const rootAfterProps: TextBlockProps = {
text: "루트2",
align: "left",
size: "base",
} as TextBlockProps;
const rootBefore: Block = {
id: "root_order_before",
type: "text",
props: rootBeforeProps,
} as any;
const sectionWithText = makeSectionWithText();
const rootAfter: Block = {
id: "root_order_after",
type: "text",
props: rootAfterProps,
} as any;
const blocks: Block[] = [rootBefore, ...sectionWithText, rootAfter];
const { container } = render(<PublicPageRenderer blocks={blocks} />);
const sections = Array.from(container.querySelectorAll("section"));
expect(sections.length).toBe(3);
expect(sections[0].className).toContain("pb-root");
expect(sections[1].className).toContain("pb-section");
expect(sections[2].className).toContain("pb-root");
expect(sections[0].textContent).toContain("루트1");
expect(sections[1].textContent).toContain("섹션 레이아웃 텍스트");
expect(sections[2].textContent).toContain("루트2");
});
});
@@ -363,4 +363,98 @@ describe("SectionPropertiesPanel", () => {
}),
);
});
it("SectionPropertiesPanel 배경 이미지/비디오 소스 및 URL 인풋은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
backgroundVideoSrc: "https://example.com/bg-video.mp4",
backgroundVideoSourceType: "externalUrl",
} as any}
selectedBlockId="section-theme-1"
updateBlock={updateBlock}
/>,
);
const bgImageSourceSelect = screen.getByLabelText("배경 이미지 소스") as HTMLSelectElement;
const bgImageSourceClass = bgImageSourceSelect.getAttribute("class") ?? "";
expect(bgImageSourceClass).toContain("bg-white");
expect(bgImageSourceClass).toContain("text-slate-900");
expect(bgImageSourceClass).toContain("border-slate-300");
expect(bgImageSourceClass).toContain("dark:bg-slate-900");
expect(bgImageSourceClass).toContain("dark:text-slate-100");
expect(bgImageSourceClass).toContain("dark:border-slate-700");
const bgImageUrlInput = screen.getByLabelText("배경 이미지 URL") as HTMLInputElement;
const bgImageUrlClass = bgImageUrlInput.getAttribute("class") ?? "";
expect(bgImageUrlClass).toContain("bg-white");
expect(bgImageUrlClass).toContain("text-slate-900");
expect(bgImageUrlClass).toContain("border-slate-300");
expect(bgImageUrlClass).toContain("dark:bg-slate-900");
expect(bgImageUrlClass).toContain("dark:text-slate-100");
expect(bgImageUrlClass).toContain("dark:border-slate-700");
const bgVideoUrlInput = screen.getByLabelText("배경 비디오 URL") as HTMLInputElement;
const bgVideoUrlClass = bgVideoUrlInput.getAttribute("class") ?? "";
expect(bgVideoUrlClass).toContain("bg-white");
expect(bgVideoUrlClass).toContain("text-slate-900");
expect(bgVideoUrlClass).toContain("border-slate-300");
expect(bgVideoUrlClass).toContain("dark:bg-slate-900");
expect(bgVideoUrlClass).toContain("dark:text-slate-100");
expect(bgVideoUrlClass).toContain("dark:border-slate-700");
});
it("섹션 레이아웃 및 배경 이미지 레이아웃 셀렉트들은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<SectionPropertiesPanel
sectionProps={{
...baseProps,
columns: [
{ id: "col_1", span: 6 },
{ id: "col_2", span: 6 },
],
alignItems: "top",
backgroundImageSrc: "https://example.com/bg.png",
backgroundImageSourceType: "externalUrl",
backgroundImagePositionMode: "preset",
backgroundImageSize: "cover",
backgroundImagePosition: "center",
backgroundImageRepeat: "no-repeat",
} as any}
selectedBlockId="section-theme-layout-1"
updateBlock={updateBlock}
/>,
);
const columnLayoutSelect = screen.getByLabelText("섹션 컬럼 레이아웃") as HTMLSelectElement;
const alignItemsSelect = screen.getByLabelText("섹션 컬럼 세로 정렬") as HTMLSelectElement;
const positionModeSelect = screen.getByLabelText("배경 이미지 위치 모드") as HTMLSelectElement;
const sizeSelect = screen.getByLabelText("배경 이미지 크기") as HTMLSelectElement;
const positionSelect = screen.getByLabelText("배경 이미지 위치") as HTMLSelectElement;
const repeatSelect = screen.getByLabelText("배경 이미지 반복") as HTMLSelectElement;
const assertDualTheme = (el: HTMLElement) => {
const className = el.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
};
assertDualTheme(columnLayoutSelect);
assertDualTheme(alignItemsSelect);
assertDualTheme(positionModeSelect);
assertDualTheme(sizeSelect);
assertDualTheme(positionSelect);
assertDualTheme(repeatSelect);
});
});
+200 -4
View File
@@ -24,7 +24,7 @@ describe("SignupPage", () => {
vi.clearAllMocks();
});
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /projects 로 이동해야 한다", async () => {
it("새 이메일/비밀번호로 회원가입하면 /api/auth/signup 으로 요청을 보내고 /dashboard 로 이동해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
@@ -56,10 +56,12 @@ describe("SignupPage", () => {
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
@@ -82,7 +84,7 @@ describe("SignupPage", () => {
expect(body.password).toBe("securePass1");
await waitFor(() => {
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
@@ -118,10 +120,12 @@ describe("SignupPage", () => {
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "dup@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "securePass1" } });
fireEvent.click(submitButton);
@@ -129,7 +133,7 @@ describe("SignupPage", () => {
expect(errorText).toBeTruthy();
});
it("이미 로그인된 상태에서 /signup 에 접근하면 /projects 로 리다이렉트해야 한다", async () => {
it("이미 로그인된 상태에서 /signup 에 접근하면 /dashboard 로 리다이렉트해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "1", email: "user@example.com", tokenVersion: 1 }), {
status: 200,
@@ -144,7 +148,199 @@ describe("SignupPage", () => {
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe("/api/auth/me");
expect(pushMock).toHaveBeenCalledWith("/projects");
expect(pushMock).toHaveBeenCalledWith("/dashboard");
});
});
it("회원가입 페이지는 전역 라이트/다크 테마 배경 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const main = await screen.findByRole("main");
const className = (main as HTMLElement).className;
expect(className).toContain("bg-slate-100");
expect(className).toContain("dark:bg-slate-950");
});
it("회원가입 폼의 비밀번호/비밀번호 확인 입력은 이메일 입력과 동일한 라이트/다크 테마 클래스를 사용해야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const emailClass = emailInput.className;
const passwordClass = passwordInput.className;
const confirmClass = confirmInput.className;
expect(emailClass).toContain("border-slate-300");
expect(emailClass).toContain("bg-white");
expect(emailClass).toContain("text-slate-900");
expect(emailClass).toContain("dark:border-slate-700");
expect(emailClass).toContain("dark:bg-slate-950");
expect(emailClass).toContain("dark:text-slate-50");
expect(passwordClass).toBe(emailClass);
expect(confirmClass).toBe(emailClass);
});
it("회원가입 폼에는 비밀번호 확인 입력 필드가 있어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const confirmInput = await screen.findByLabelText("비밀번호 확인");
expect(confirmInput).toBeTruthy();
});
it("비밀번호와 비밀번호 확인이 일치하지 않으면 회원가입 요청을 보내지 말고 오류 메시지를 표시해야 한다", async () => {
const fetchMock = vi.fn().mockImplementation((input: any, init?: any) => {
const url = typeof input === "string" ? input : input.url;
const method = init?.method ?? "GET";
if (url === "/api/auth/me" && method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
}
if (url === "/api/auth/signup" && method === "POST") {
return Promise.resolve(
new Response(JSON.stringify({ id: "1", email: "new@example.com" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
}
return Promise.resolve(new Response(null, { status: 500 }));
});
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const emailInput = screen.getByLabelText("이메일") as HTMLInputElement;
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
const confirmInput = screen.getByLabelText("비밀번호 확인") as HTMLInputElement;
const submitButton = screen.getByRole("button", { name: "회원가입" });
fireEvent.change(emailInput, { target: { value: "new@example.com" } });
fireEvent.change(passwordInput, { target: { value: "securePass1" } });
fireEvent.change(confirmInput, { target: { value: "differentPass2" } });
fireEvent.click(submitButton);
const errorText = await screen.findByText(/비밀번호와 비밀번호 확인이 일치하지 않습니다/);
expect(errorText).toBeTruthy();
const signupCall = fetchMock.mock.calls.find(([url, options]) => {
return url === "/api/auth/signup" && options?.method === "POST";
});
expect(signupCall).toBeUndefined();
});
it("짧고 단순한 비밀번호는 난이도가 약함으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "abc12345" } });
const weakText = await screen.findByText("비밀번호 난이도: 약함");
expect(weakText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-1/3");
expect(barClass).toContain("bg-red-500");
});
it("대소문자와 숫자를 섞은 비밀번호는 난이도가 보통으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "Abc12345" } });
const mediumText = await screen.findByText("비밀번호 난이도: 보통");
expect(mediumText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-2/3");
expect(barClass).toContain("bg-amber-500");
});
it("대소문자/숫자/특수문자가 섞인 충분히 긴 비밀번호는 난이도가 강함으로 표시되어야 한다", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: "인증이 필요합니다." }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock as any);
render(<SignupPage />);
const passwordInput = screen.getByLabelText("비밀번호") as HTMLInputElement;
fireEvent.change(passwordInput, { target: { value: "Abc12345!@" } });
const strongText = await screen.findByText("비밀번호 난이도: 강함");
expect(strongText).toBeTruthy();
const bar = await screen.findByTestId("password-strength-bar");
const barClass = bar.className;
expect(barClass).toContain("w-full");
expect(barClass).toContain("bg-emerald-600");
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { TextPropertiesPanel } from "@/app/editor/panels/TextPropertiesPanel";
import type { TextBlockProps } from "@/features/editor/state/editorStore";
// TextPropertiesPanel 컨트롤 라이트/다크 테마 TDD
describe("TextPropertiesPanel", () => {
const baseProps: TextBlockProps = {
text: "텍스트",
align: "left",
size: "base",
} as any;
afterEach(() => {
cleanup();
});
it("텍스트 정렬 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-align"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
const select = screen.getByLabelText("정렬") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
it("글자 간격 프리셋 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-letter-spacing"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
const select = screen.getByLabelText("글자 간격 프리셋") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
it("최대 너비 프리셋 셀렉트는 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-max-width-select"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
const select = screen.getByLabelText("최대 너비 프리셋") as HTMLSelectElement;
const className = select.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
it("최대 너비 커스텀 인풋은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<TextPropertiesPanel
textProps={baseProps}
selectedBlockId="text-max-width-input"
updateBlock={updateBlock}
editingBlockId={null}
setEditingText={() => {}}
/>,
);
const input = screen.getByLabelText("최대 너비 커스텀") as HTMLInputElement;
const className = input.getAttribute("class") ?? "";
expect(className).toContain("bg-white");
expect(className).toContain("text-slate-900");
expect(className).toContain("border-slate-300");
expect(className).toContain("dark:bg-slate-900");
expect(className).toContain("dark:text-slate-100");
expect(className).toContain("dark:border-slate-700");
});
});
+39
View File
@@ -293,4 +293,43 @@ describe("VideoPropertiesPanel", () => {
expect.objectContaining({ captionText: "새 캡션 텍스트" }),
);
});
it("VideoPropertiesPanel 주요 인풋/셀렉트 컨트롤은 라이트/다크 듀얼 테마 크롬을 사용해야 한다", () => {
const updateBlock = vi.fn();
render(
<VideoPropertiesPanel
videoProps={{ ...baseProps, sourceType: "externalUrl" } as any}
selectedBlockId="video-theme-1"
updateBlock={updateBlock}
/>,
);
const sourceSelect = screen.getByLabelText("비디오 소스") as HTMLSelectElement;
const sourceClass = sourceSelect.getAttribute("class") ?? "";
expect(sourceClass).toContain("bg-white");
expect(sourceClass).toContain("text-slate-900");
expect(sourceClass).toContain("border-slate-300");
expect(sourceClass).toContain("dark:bg-slate-900");
expect(sourceClass).toContain("dark:text-slate-100");
expect(sourceClass).toContain("dark:border-slate-700");
const urlInput = screen.getByLabelText("비디오 URL") as HTMLInputElement;
const urlClass = urlInput.getAttribute("class") ?? "";
expect(urlClass).toContain("bg-white");
expect(urlClass).toContain("text-slate-900");
expect(urlClass).toContain("border-slate-300");
expect(urlClass).toContain("dark:bg-slate-900");
expect(urlClass).toContain("dark:text-slate-100");
expect(urlClass).toContain("dark:border-slate-700");
const alignSelect = screen.getByLabelText("비디오 정렬") as HTMLSelectElement;
const alignClass = alignSelect.getAttribute("class") ?? "";
expect(alignClass).toContain("bg-white");
expect(alignClass).toContain("text-slate-900");
expect(alignClass).toContain("border-slate-300");
expect(alignClass).toContain("dark:bg-slate-900");
expect(alignClass).toContain("dark:text-slate-100");
expect(alignClass).toContain("dark:border-slate-700");
});
});
+43
View File
@@ -300,6 +300,16 @@ describe("formHelpers - public tokens", () => {
expectNoPxInStyleObject(tokens.inputStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormInputPublicTokens: 색상 커스텀 값이 없으면 color/backgroundColor/borderColor 를 설정하지 않아야 한다", () => {
const tokens = computeFormInputPublicTokens({
...baseInput,
} as any);
expect(tokens.inputStyle.color).toBeUndefined();
expect(tokens.inputStyle.backgroundColor).toBeUndefined();
expect(tokens.inputStyle.borderColor).toBeUndefined();
});
it("computeFormSelectPublicTokens: 퍼블릭 셀렉트 필드 스타일을 계산해야 한다", () => {
const tokens = computeFormSelectPublicTokens({
...baseSelect,
@@ -327,6 +337,17 @@ describe("formHelpers - public tokens", () => {
expectNoPxInStyleObject(tokens.selectStyle, { allowKeys: ["borderRadius", "borderWidth"] });
});
it("computeFormSelectPublicTokens: 색상 커스텀 값이 없으면 color/backgroundColor/borderColor 를 설정하지 않아야 한다", () => {
const tokens = computeFormSelectPublicTokens({
...baseSelect,
} as any);
expect(tokens.wrapperStyle.color).toBeUndefined();
expect(tokens.selectStyle.color).toBeUndefined();
expect(tokens.selectStyle.backgroundColor).toBeUndefined();
expect(tokens.selectStyle.borderColor).toBeUndefined();
});
it("computeFormCheckboxPublicTokens: 퍼블릭 체크박스 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormCheckboxPublicTokens({
...baseCheckbox,
@@ -362,6 +383,17 @@ describe("formHelpers - public tokens", () => {
expectNoPxInStyleObject(tokens.optionTextStyle);
});
it("computeFormCheckboxPublicTokens: 색상 커스텀 값이 없으면 텍스트/옵션 컨테이너에 색상 스타일을 설정하지 않아야 한다", () => {
const tokens = computeFormCheckboxPublicTokens({
...baseCheckbox,
} as any);
expect(tokens.groupTextStyle.color).toBeUndefined();
expect(tokens.optionTextStyle.color).toBeUndefined();
expect(tokens.optionContainerStyle.backgroundColor).toBeUndefined();
expect(tokens.optionContainerStyle.borderColor).toBeUndefined();
});
it("computeFormRadioPublicTokens: 퍼블릭 라디오 그룹 스타일을 계산해야 한다", () => {
const tokens = computeFormRadioPublicTokens({
...baseRadio,
@@ -389,6 +421,17 @@ describe("formHelpers - public tokens", () => {
expect(tokens.groupTextStyle.color).toBe("#ffffff");
expect(tokens.optionTextStyle.color).toBe("#ffffff");
});
it("computeFormRadioPublicTokens: 색상 커스텀 값이 없으면 텍스트/옵션 컨테이너에 색상 스타일을 설정하지 않아야 한다", () => {
const tokens = computeFormRadioPublicTokens({
...baseRadio,
} as any);
expect(tokens.groupTextStyle.color).toBeUndefined();
expect(tokens.optionTextStyle.color).toBeUndefined();
expect(tokens.optionContainerStyle.backgroundColor).toBeUndefined();
expect(tokens.optionContainerStyle.borderColor).toBeUndefined();
});
});
describe("formHelpers.computeFormControllerPublicTokens", () => {
+1
View File
@@ -0,0 +1 @@
dummy-image
+1
View File
@@ -0,0 +1 @@
dummy-image
@@ -0,0 +1 @@
dummy-section-video
+1
View File
@@ -0,0 +1 @@
dummy-video
+1
View File
@@ -0,0 +1 @@
dummy-poster