레이아웃 추가기능
This commit is contained in:
parent
f7aa71ec30
commit
083f053851
|
|
@ -5020,6 +5020,10 @@ model screen_layouts {
|
||||||
height Int
|
height Int
|
||||||
properties Json?
|
properties Json?
|
||||||
display_order Int @default(0)
|
display_order Int @default(0)
|
||||||
|
layout_type String? @db.VarChar(50)
|
||||||
|
layout_config Json?
|
||||||
|
zones_config Json?
|
||||||
|
zone_id String? @db.VarChar(100)
|
||||||
created_date DateTime @default(now()) @db.Timestamp(6)
|
created_date DateTime @default(now()) @db.Timestamp(6)
|
||||||
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
|
screen screen_definitions @relation(fields: [screen_id], references: [screen_id], onDelete: Cascade)
|
||||||
widgets screen_widgets[]
|
widgets screen_widgets[]
|
||||||
|
|
@ -5302,3 +5306,30 @@ model component_standards {
|
||||||
@@index([category], map: "idx_component_standards_category")
|
@@index([category], map: "idx_component_standards_category")
|
||||||
@@index([company_code], map: "idx_component_standards_company")
|
@@index([company_code], map: "idx_component_standards_company")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 레이아웃 표준 관리 테이블
|
||||||
|
model layout_standards {
|
||||||
|
layout_code String @id @db.VarChar(50)
|
||||||
|
layout_name String @db.VarChar(100)
|
||||||
|
layout_name_eng String? @db.VarChar(100)
|
||||||
|
description String? @db.Text
|
||||||
|
layout_type String @db.VarChar(50)
|
||||||
|
category String @db.VarChar(50)
|
||||||
|
icon_name String? @db.VarChar(50)
|
||||||
|
default_size Json? // { width: number, height: number }
|
||||||
|
layout_config Json // 레이아웃 설정 (그리드, 플렉스박스 등)
|
||||||
|
zones_config Json // 존 설정 (영역 정의)
|
||||||
|
preview_image String? @db.VarChar(255)
|
||||||
|
sort_order Int? @default(0)
|
||||||
|
is_active String? @default("Y") @db.Char(1)
|
||||||
|
is_public String? @default("Y") @db.Char(1)
|
||||||
|
company_code String @db.VarChar(50)
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
created_by String? @db.VarChar(50)
|
||||||
|
updated_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_by String? @db.VarChar(50)
|
||||||
|
|
||||||
|
@@index([layout_type], map: "idx_layout_standards_type")
|
||||||
|
@@index([category], map: "idx_layout_standards_category")
|
||||||
|
@@index([company_code], map: "idx_layout_standards_company")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function addMissingColumns() {
|
||||||
|
try {
|
||||||
|
console.log("🔄 누락된 컬럼들을 screen_layouts 테이블에 추가 중...");
|
||||||
|
|
||||||
|
// layout_type 컬럼 추가
|
||||||
|
try {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
ALTER TABLE screen_layouts
|
||||||
|
ADD COLUMN IF NOT EXISTS layout_type VARCHAR(50);
|
||||||
|
`;
|
||||||
|
console.log("✅ layout_type 컬럼 추가 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"ℹ️ layout_type 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// layout_config 컬럼 추가
|
||||||
|
try {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
ALTER TABLE screen_layouts
|
||||||
|
ADD COLUMN IF NOT EXISTS layout_config JSONB;
|
||||||
|
`;
|
||||||
|
console.log("✅ layout_config 컬럼 추가 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"ℹ️ layout_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// zones_config 컬럼 추가
|
||||||
|
try {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
ALTER TABLE screen_layouts
|
||||||
|
ADD COLUMN IF NOT EXISTS zones_config JSONB;
|
||||||
|
`;
|
||||||
|
console.log("✅ zones_config 컬럼 추가 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"ℹ️ zones_config 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// zone_id 컬럼 추가
|
||||||
|
try {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
ALTER TABLE screen_layouts
|
||||||
|
ADD COLUMN IF NOT EXISTS zone_id VARCHAR(100);
|
||||||
|
`;
|
||||||
|
console.log("✅ zone_id 컬럼 추가 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.log(
|
||||||
|
"ℹ️ zone_id 컬럼이 이미 존재하거나 추가 중 오류:",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인덱스 생성 (성능 향상)
|
||||||
|
try {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_screen_layouts_layout_type
|
||||||
|
ON screen_layouts(layout_type);
|
||||||
|
`;
|
||||||
|
console.log("✅ layout_type 인덱스 생성 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("ℹ️ layout_type 인덱스 생성 중 오류:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_screen_layouts_zone_id
|
||||||
|
ON screen_layouts(zone_id);
|
||||||
|
`;
|
||||||
|
console.log("✅ zone_id 인덱스 생성 완료");
|
||||||
|
} catch (error) {
|
||||||
|
console.log("ℹ️ zone_id 인덱스 생성 중 오류:", error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최종 테이블 구조 확인
|
||||||
|
const columns = await prisma.$queryRaw`
|
||||||
|
SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'screen_layouts'
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log("\n📋 screen_layouts 테이블 최종 구조:");
|
||||||
|
console.table(columns);
|
||||||
|
|
||||||
|
console.log("\n🎉 모든 누락된 컬럼 추가 작업이 완료되었습니다!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 컬럼 추가 중 오류 발생:", error);
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMissingColumns();
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
/**
|
||||||
|
* 레이아웃 표준 데이터 초기화 스크립트
|
||||||
|
* 기본 레이아웃들을 layout_standards 테이블에 삽입합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 기본 레이아웃 데이터
|
||||||
|
const PREDEFINED_LAYOUTS = [
|
||||||
|
{
|
||||||
|
layout_code: "GRID_2X2_001",
|
||||||
|
layout_name: "2x2 그리드",
|
||||||
|
layout_name_eng: "2x2 Grid",
|
||||||
|
description: "2행 2열의 균등한 그리드 레이아웃입니다.",
|
||||||
|
layout_type: "grid",
|
||||||
|
category: "basic",
|
||||||
|
icon_name: "grid",
|
||||||
|
default_size: { width: 800, height: 600 },
|
||||||
|
layout_config: {
|
||||||
|
grid: { rows: 2, columns: 2, gap: 16 },
|
||||||
|
},
|
||||||
|
zones_config: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "상단 좌측",
|
||||||
|
position: { row: 0, column: 0 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "상단 우측",
|
||||||
|
position: { row: 0, column: 1 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "하단 좌측",
|
||||||
|
position: { row: 1, column: 0 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone4",
|
||||||
|
name: "하단 우측",
|
||||||
|
position: { row: 1, column: 1 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sort_order: 1,
|
||||||
|
is_active: "Y",
|
||||||
|
is_public: "Y",
|
||||||
|
company_code: "DEFAULT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout_code: "FORM_TWO_COLUMN_001",
|
||||||
|
layout_name: "2단 폼 레이아웃",
|
||||||
|
layout_name_eng: "Two Column Form",
|
||||||
|
description: "좌우 2단으로 구성된 폼 레이아웃입니다.",
|
||||||
|
layout_type: "grid",
|
||||||
|
category: "form",
|
||||||
|
icon_name: "columns",
|
||||||
|
default_size: { width: 800, height: 400 },
|
||||||
|
layout_config: {
|
||||||
|
grid: { rows: 1, columns: 2, gap: 24 },
|
||||||
|
},
|
||||||
|
zones_config: [
|
||||||
|
{
|
||||||
|
id: "left",
|
||||||
|
name: "좌측 입력 영역",
|
||||||
|
position: { row: 0, column: 0 },
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
name: "우측 입력 영역",
|
||||||
|
position: { row: 0, column: 1 },
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sort_order: 2,
|
||||||
|
is_active: "Y",
|
||||||
|
is_public: "Y",
|
||||||
|
company_code: "DEFAULT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout_code: "FLEXBOX_ROW_001",
|
||||||
|
layout_name: "가로 플렉스박스",
|
||||||
|
layout_name_eng: "Horizontal Flexbox",
|
||||||
|
description: "가로 방향으로 배치되는 플렉스박스 레이아웃입니다.",
|
||||||
|
layout_type: "flexbox",
|
||||||
|
category: "basic",
|
||||||
|
icon_name: "flex",
|
||||||
|
default_size: { width: 800, height: 300 },
|
||||||
|
layout_config: {
|
||||||
|
flexbox: {
|
||||||
|
direction: "row",
|
||||||
|
justify: "flex-start",
|
||||||
|
align: "stretch",
|
||||||
|
wrap: "nowrap",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zones_config: [
|
||||||
|
{
|
||||||
|
id: "left",
|
||||||
|
name: "좌측 영역",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
name: "우측 영역",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sort_order: 3,
|
||||||
|
is_active: "Y",
|
||||||
|
is_public: "Y",
|
||||||
|
company_code: "DEFAULT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout_code: "SPLIT_HORIZONTAL_001",
|
||||||
|
layout_name: "수평 분할",
|
||||||
|
layout_name_eng: "Horizontal Split",
|
||||||
|
description: "크기 조절이 가능한 수평 분할 레이아웃입니다.",
|
||||||
|
layout_type: "split",
|
||||||
|
category: "basic",
|
||||||
|
icon_name: "separator-horizontal",
|
||||||
|
default_size: { width: 800, height: 400 },
|
||||||
|
layout_config: {
|
||||||
|
split: {
|
||||||
|
direction: "horizontal",
|
||||||
|
ratio: [50, 50],
|
||||||
|
minSize: [200, 200],
|
||||||
|
resizable: true,
|
||||||
|
splitterSize: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zones_config: [
|
||||||
|
{
|
||||||
|
id: "left",
|
||||||
|
name: "좌측 패널",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
isResizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
name: "우측 패널",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
isResizable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sort_order: 4,
|
||||||
|
is_active: "Y",
|
||||||
|
is_public: "Y",
|
||||||
|
company_code: "DEFAULT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout_code: "TABS_HORIZONTAL_001",
|
||||||
|
layout_name: "수평 탭",
|
||||||
|
layout_name_eng: "Horizontal Tabs",
|
||||||
|
description: "상단에 탭이 있는 탭 레이아웃입니다.",
|
||||||
|
layout_type: "tabs",
|
||||||
|
category: "navigation",
|
||||||
|
icon_name: "tabs",
|
||||||
|
default_size: { width: 800, height: 500 },
|
||||||
|
layout_config: {
|
||||||
|
tabs: {
|
||||||
|
position: "top",
|
||||||
|
variant: "default",
|
||||||
|
size: "md",
|
||||||
|
defaultTab: "tab1",
|
||||||
|
closable: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zones_config: [
|
||||||
|
{
|
||||||
|
id: "tab1",
|
||||||
|
name: "첫 번째 탭",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab2",
|
||||||
|
name: "두 번째 탭",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab3",
|
||||||
|
name: "세 번째 탭",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sort_order: 5,
|
||||||
|
is_active: "Y",
|
||||||
|
is_public: "Y",
|
||||||
|
company_code: "DEFAULT",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
layout_code: "TABLE_WITH_FILTERS_001",
|
||||||
|
layout_name: "필터가 있는 테이블",
|
||||||
|
layout_name_eng: "Table with Filters",
|
||||||
|
description: "상단에 필터가 있고 하단에 테이블이 있는 레이아웃입니다.",
|
||||||
|
layout_type: "flexbox",
|
||||||
|
category: "table",
|
||||||
|
icon_name: "table",
|
||||||
|
default_size: { width: 1000, height: 600 },
|
||||||
|
layout_config: {
|
||||||
|
flexbox: {
|
||||||
|
direction: "column",
|
||||||
|
justify: "flex-start",
|
||||||
|
align: "stretch",
|
||||||
|
wrap: "nowrap",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zones_config: [
|
||||||
|
{
|
||||||
|
id: "filters",
|
||||||
|
name: "검색 필터",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "auto" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "table",
|
||||||
|
name: "데이터 테이블",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "1fr" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sort_order: 6,
|
||||||
|
is_active: "Y",
|
||||||
|
is_public: "Y",
|
||||||
|
company_code: "DEFAULT",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function initializeLayoutStandards() {
|
||||||
|
try {
|
||||||
|
console.log("🏗️ 레이아웃 표준 데이터 초기화 시작...");
|
||||||
|
|
||||||
|
// 기존 데이터 확인
|
||||||
|
const existingLayouts = await prisma.layout_standards.count();
|
||||||
|
if (existingLayouts > 0) {
|
||||||
|
console.log(`⚠️ 이미 ${existingLayouts}개의 레이아웃이 존재합니다.`);
|
||||||
|
console.log(
|
||||||
|
"기존 데이터를 삭제하고 새로 생성하시겠습니까? (기본값: 건너뛰기)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 기존 데이터가 있으면 건너뛰기 (안전을 위해)
|
||||||
|
console.log("💡 기존 데이터를 유지하고 건너뜁니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 삽입
|
||||||
|
let insertedCount = 0;
|
||||||
|
|
||||||
|
for (const layoutData of PREDEFINED_LAYOUTS) {
|
||||||
|
try {
|
||||||
|
await prisma.layout_standards.create({
|
||||||
|
data: {
|
||||||
|
...layoutData,
|
||||||
|
created_date: new Date(),
|
||||||
|
updated_date: new Date(),
|
||||||
|
created_by: "SYSTEM",
|
||||||
|
updated_by: "SYSTEM",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ ${layoutData.layout_name} 생성 완료`);
|
||||||
|
insertedCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ ${layoutData.layout_name} 생성 실패:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🎉 레이아웃 표준 데이터 초기화 완료! (${insertedCount}/${PREDEFINED_LAYOUTS.length})`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 레이아웃 표준 데이터 초기화 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크립트 실행
|
||||||
|
if (require.main === module) {
|
||||||
|
initializeLayoutStandards()
|
||||||
|
.then(() => {
|
||||||
|
console.log("✨ 스크립트 실행 완료");
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("💥 스크립트 실행 실패:", error);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { initializeLayoutStandards };
|
||||||
|
|
||||||
|
|
@ -24,6 +24,7 @@ import buttonActionStandardRoutes from "./routes/buttonActionStandardRoutes";
|
||||||
import screenStandardRoutes from "./routes/screenStandardRoutes";
|
import screenStandardRoutes from "./routes/screenStandardRoutes";
|
||||||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||||
|
import layoutRoutes from "./routes/layoutRoutes";
|
||||||
// import userRoutes from './routes/userRoutes';
|
// import userRoutes from './routes/userRoutes';
|
||||||
// import menuRoutes from './routes/menuRoutes';
|
// import menuRoutes from './routes/menuRoutes';
|
||||||
|
|
||||||
|
|
@ -110,6 +111,7 @@ app.use("/api/admin/web-types", webTypeStandardRoutes);
|
||||||
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
||||||
app.use("/api/admin/template-standards", templateStandardRoutes);
|
app.use("/api/admin/template-standards", templateStandardRoutes);
|
||||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||||
|
app.use("/api/layouts", layoutRoutes);
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
// app.use('/api/menus', menuRoutes);
|
// app.use('/api/menus', menuRoutes);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { layoutService } from "../services/layoutService";
|
||||||
|
import {
|
||||||
|
CreateLayoutRequest,
|
||||||
|
UpdateLayoutRequest,
|
||||||
|
GetLayoutsRequest,
|
||||||
|
DuplicateLayoutRequest,
|
||||||
|
} from "../types/layout";
|
||||||
|
|
||||||
|
export class LayoutController {
|
||||||
|
/**
|
||||||
|
* 레이아웃 목록 조회
|
||||||
|
*/
|
||||||
|
async getLayouts(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { user } = req as any;
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
size = 20,
|
||||||
|
category,
|
||||||
|
layoutType,
|
||||||
|
searchTerm,
|
||||||
|
includePublic = true,
|
||||||
|
} = req.query as any;
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
page: parseInt(page, 10),
|
||||||
|
size: parseInt(size, 10),
|
||||||
|
category,
|
||||||
|
layoutType,
|
||||||
|
searchTerm,
|
||||||
|
companyCode: user.companyCode,
|
||||||
|
includePublic: includePublic === "true",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await layoutService.getLayouts(params);
|
||||||
|
|
||||||
|
const response = {
|
||||||
|
...result,
|
||||||
|
page: params.page,
|
||||||
|
size: params.size,
|
||||||
|
totalPages: Math.ceil(result.total / params.size),
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: response,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 목록 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 목록 조회에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 상세 조회
|
||||||
|
*/
|
||||||
|
async getLayoutById(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { user } = req as any;
|
||||||
|
const { id: layoutCode } = req.params;
|
||||||
|
|
||||||
|
const layout = await layoutService.getLayoutById(
|
||||||
|
layoutCode,
|
||||||
|
user.companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!layout) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃을 찾을 수 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: layout,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 상세 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 상세 조회에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 생성
|
||||||
|
*/
|
||||||
|
async createLayout(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { user } = req as any;
|
||||||
|
const layoutRequest: CreateLayoutRequest = req.body;
|
||||||
|
|
||||||
|
// 요청 데이터 검증
|
||||||
|
if (
|
||||||
|
!layoutRequest.layoutName ||
|
||||||
|
!layoutRequest.layoutType ||
|
||||||
|
!layoutRequest.category
|
||||||
|
) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
"필수 필드가 누락되었습니다. (layoutName, layoutType, category)",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layoutRequest.layoutConfig || !layoutRequest.zonesConfig) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 설정과 존 설정은 필수입니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = await layoutService.createLayout(
|
||||||
|
layoutRequest,
|
||||||
|
user.companyCode,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: layout,
|
||||||
|
message: "레이아웃이 성공적으로 생성되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 생성 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 생성에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 수정
|
||||||
|
*/
|
||||||
|
async updateLayout(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { user } = req as any;
|
||||||
|
const { id: layoutCode } = req.params;
|
||||||
|
const updateRequest: Partial<CreateLayoutRequest> = req.body;
|
||||||
|
|
||||||
|
const updatedLayout = await layoutService.updateLayout(
|
||||||
|
{ ...updateRequest, layoutCode },
|
||||||
|
user.companyCode,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedLayout) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃을 찾을 수 없거나 수정 권한이 없습니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: updatedLayout,
|
||||||
|
message: "레이아웃이 성공적으로 수정되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 수정 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 수정에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 삭제
|
||||||
|
*/
|
||||||
|
async deleteLayout(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { user } = req as any;
|
||||||
|
const { id: layoutCode } = req.params;
|
||||||
|
|
||||||
|
await layoutService.deleteLayout(
|
||||||
|
layoutCode,
|
||||||
|
user.companyCode,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "레이아웃이 성공적으로 삭제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 삭제 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 삭제에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 복제
|
||||||
|
*/
|
||||||
|
async duplicateLayout(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { user } = req as any;
|
||||||
|
const { id: layoutCode } = req.params;
|
||||||
|
const { newName }: DuplicateLayoutRequest = req.body;
|
||||||
|
|
||||||
|
if (!newName) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "새 레이아웃 이름이 필요합니다.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicatedLayout = await layoutService.duplicateLayout(
|
||||||
|
layoutCode,
|
||||||
|
newName,
|
||||||
|
user.companyCode,
|
||||||
|
user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: duplicatedLayout,
|
||||||
|
message: "레이아웃이 성공적으로 복제되었습니다.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 복제 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 복제에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 레이아웃 개수 조회
|
||||||
|
*/
|
||||||
|
async getLayoutCountsByCategory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { user } = req as any;
|
||||||
|
|
||||||
|
const counts = await layoutService.getLayoutCountsByCategory(
|
||||||
|
user.companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: counts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리별 레이아웃 개수 조회 오류:", error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "카테고리별 레이아웃 개수 조회에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layoutController = new LayoutController();
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { layoutController } from "../controllers/layoutController";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 레이아웃 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/layouts
|
||||||
|
* @desc 레이아웃 목록 조회
|
||||||
|
* @access Private
|
||||||
|
* @params page, size, category, layoutType, searchTerm, includePublic
|
||||||
|
*/
|
||||||
|
router.get("/", layoutController.getLayouts.bind(layoutController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/layouts/counts-by-category
|
||||||
|
* @desc 카테고리별 레이아웃 개수 조회
|
||||||
|
* @access Private
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/counts-by-category",
|
||||||
|
layoutController.getLayoutCountsByCategory.bind(layoutController)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route GET /api/layouts/:id
|
||||||
|
* @desc 레이아웃 상세 조회
|
||||||
|
* @access Private
|
||||||
|
* @params id (layoutCode)
|
||||||
|
*/
|
||||||
|
router.get("/:id", layoutController.getLayoutById.bind(layoutController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/layouts
|
||||||
|
* @desc 레이아웃 생성
|
||||||
|
* @access Private
|
||||||
|
* @body CreateLayoutRequest
|
||||||
|
*/
|
||||||
|
router.post("/", layoutController.createLayout.bind(layoutController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/layouts/:id
|
||||||
|
* @desc 레이아웃 수정
|
||||||
|
* @access Private
|
||||||
|
* @params id (layoutCode)
|
||||||
|
* @body Partial<CreateLayoutRequest>
|
||||||
|
*/
|
||||||
|
router.put("/:id", layoutController.updateLayout.bind(layoutController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/layouts/:id
|
||||||
|
* @desc 레이아웃 삭제
|
||||||
|
* @access Private
|
||||||
|
* @params id (layoutCode)
|
||||||
|
*/
|
||||||
|
router.delete("/:id", layoutController.deleteLayout.bind(layoutController));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route POST /api/layouts/:id/duplicate
|
||||||
|
* @desc 레이아웃 복제
|
||||||
|
* @access Private
|
||||||
|
* @params id (layoutCode)
|
||||||
|
* @body { newName: string }
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/duplicate",
|
||||||
|
layoutController.duplicateLayout.bind(layoutController)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,425 @@
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
CreateLayoutRequest,
|
||||||
|
UpdateLayoutRequest,
|
||||||
|
LayoutStandard,
|
||||||
|
LayoutType,
|
||||||
|
LayoutCategory,
|
||||||
|
} from "../types/layout";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// JSON 데이터를 안전하게 파싱하는 헬퍼 함수
|
||||||
|
function safeJSONParse(data: any): any {
|
||||||
|
if (data === null || data === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 객체인 경우 그대로 반환
|
||||||
|
if (typeof data === "object") {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문자열인 경우 파싱 시도
|
||||||
|
if (typeof data === "string") {
|
||||||
|
try {
|
||||||
|
return JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("JSON 파싱 오류:", error, "Data:", data);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 데이터를 안전하게 문자열화하는 헬퍼 함수
|
||||||
|
function safeJSONStringify(data: any): string | null {
|
||||||
|
if (data === null || data === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 문자열인 경우 그대로 반환
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 객체인 경우 문자열로 변환
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("JSON 문자열화 오류:", error, "Data:", data);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LayoutService {
|
||||||
|
/**
|
||||||
|
* 레이아웃 목록 조회
|
||||||
|
*/
|
||||||
|
async getLayouts(params: {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
category?: string;
|
||||||
|
layoutType?: string;
|
||||||
|
searchTerm?: string;
|
||||||
|
companyCode: string;
|
||||||
|
includePublic?: boolean;
|
||||||
|
}): Promise<{ data: LayoutStandard[]; total: number }> {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
size = 20,
|
||||||
|
category,
|
||||||
|
layoutType,
|
||||||
|
searchTerm,
|
||||||
|
companyCode,
|
||||||
|
includePublic = true,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const skip = (page - 1) * size;
|
||||||
|
|
||||||
|
// 검색 조건 구성
|
||||||
|
const where: any = {
|
||||||
|
is_active: "Y",
|
||||||
|
OR: [
|
||||||
|
{ company_code: companyCode },
|
||||||
|
...(includePublic ? [{ is_public: "Y" }] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
where.category = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layoutType) {
|
||||||
|
where.layout_type = layoutType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchTerm) {
|
||||||
|
where.OR = [
|
||||||
|
...where.OR,
|
||||||
|
{ layout_name: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ layout_name_eng: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
{ description: { contains: searchTerm, mode: "insensitive" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [data, total] = await Promise.all([
|
||||||
|
prisma.layout_standards.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: size,
|
||||||
|
orderBy: [{ sort_order: "asc" }, { created_date: "desc" }],
|
||||||
|
}),
|
||||||
|
prisma.layout_standards.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data.map(
|
||||||
|
(layout) =>
|
||||||
|
({
|
||||||
|
layoutCode: layout.layout_code,
|
||||||
|
layoutName: layout.layout_name,
|
||||||
|
layoutNameEng: layout.layout_name_eng,
|
||||||
|
description: layout.description,
|
||||||
|
layoutType: layout.layout_type as LayoutType,
|
||||||
|
category: layout.category as LayoutCategory,
|
||||||
|
iconName: layout.icon_name,
|
||||||
|
defaultSize: safeJSONParse(layout.default_size),
|
||||||
|
layoutConfig: safeJSONParse(layout.layout_config),
|
||||||
|
zonesConfig: safeJSONParse(layout.zones_config),
|
||||||
|
previewImage: layout.preview_image,
|
||||||
|
sortOrder: layout.sort_order,
|
||||||
|
isActive: layout.is_active,
|
||||||
|
isPublic: layout.is_public,
|
||||||
|
companyCode: layout.company_code,
|
||||||
|
createdDate: layout.created_date,
|
||||||
|
createdBy: layout.created_by,
|
||||||
|
updatedDate: layout.updated_date,
|
||||||
|
updatedBy: layout.updated_by,
|
||||||
|
}) as LayoutStandard
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 상세 조회
|
||||||
|
*/
|
||||||
|
async getLayoutById(
|
||||||
|
layoutCode: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<LayoutStandard | null> {
|
||||||
|
const layout = await prisma.layout_standards.findFirst({
|
||||||
|
where: {
|
||||||
|
layout_code: layoutCode,
|
||||||
|
is_active: "Y",
|
||||||
|
OR: [{ company_code: companyCode }, { is_public: "Y" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!layout) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
layoutCode: layout.layout_code,
|
||||||
|
layoutName: layout.layout_name,
|
||||||
|
layoutNameEng: layout.layout_name_eng,
|
||||||
|
description: layout.description,
|
||||||
|
layoutType: layout.layout_type as LayoutType,
|
||||||
|
category: layout.category as LayoutCategory,
|
||||||
|
iconName: layout.icon_name,
|
||||||
|
defaultSize: safeJSONParse(layout.default_size),
|
||||||
|
layoutConfig: safeJSONParse(layout.layout_config),
|
||||||
|
zonesConfig: safeJSONParse(layout.zones_config),
|
||||||
|
previewImage: layout.preview_image,
|
||||||
|
sortOrder: layout.sort_order,
|
||||||
|
isActive: layout.is_active,
|
||||||
|
isPublic: layout.is_public,
|
||||||
|
companyCode: layout.company_code,
|
||||||
|
createdDate: layout.created_date,
|
||||||
|
createdBy: layout.created_by,
|
||||||
|
updatedDate: layout.updated_date,
|
||||||
|
updatedBy: layout.updated_by,
|
||||||
|
} as LayoutStandard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 생성
|
||||||
|
*/
|
||||||
|
async createLayout(
|
||||||
|
request: CreateLayoutRequest,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<LayoutStandard> {
|
||||||
|
// 레이아웃 코드 생성 (자동)
|
||||||
|
const layoutCode = await this.generateLayoutCode(
|
||||||
|
request.layoutType,
|
||||||
|
companyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
const layout = await prisma.layout_standards.create({
|
||||||
|
data: {
|
||||||
|
layout_code: layoutCode,
|
||||||
|
layout_name: request.layoutName,
|
||||||
|
layout_name_eng: request.layoutNameEng,
|
||||||
|
description: request.description,
|
||||||
|
layout_type: request.layoutType,
|
||||||
|
category: request.category,
|
||||||
|
icon_name: request.iconName,
|
||||||
|
default_size: safeJSONStringify(request.defaultSize) as any,
|
||||||
|
layout_config: safeJSONStringify(request.layoutConfig) as any,
|
||||||
|
zones_config: safeJSONStringify(request.zonesConfig) as any,
|
||||||
|
is_public: request.isPublic ? "Y" : "N",
|
||||||
|
company_code: companyCode,
|
||||||
|
created_by: userId,
|
||||||
|
updated_by: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapToLayoutStandard(layout);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 수정
|
||||||
|
*/
|
||||||
|
async updateLayout(
|
||||||
|
request: UpdateLayoutRequest,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<LayoutStandard | null> {
|
||||||
|
// 수정 권한 확인
|
||||||
|
const existing = await prisma.layout_standards.findFirst({
|
||||||
|
where: {
|
||||||
|
layout_code: request.layoutCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
is_active: "Y",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error("레이아웃을 찾을 수 없거나 수정 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: any = {
|
||||||
|
updated_by: userId,
|
||||||
|
updated_date: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 수정할 필드만 업데이트
|
||||||
|
if (request.layoutName !== undefined)
|
||||||
|
updateData.layout_name = request.layoutName;
|
||||||
|
if (request.layoutNameEng !== undefined)
|
||||||
|
updateData.layout_name_eng = request.layoutNameEng;
|
||||||
|
if (request.description !== undefined)
|
||||||
|
updateData.description = request.description;
|
||||||
|
if (request.layoutType !== undefined)
|
||||||
|
updateData.layout_type = request.layoutType;
|
||||||
|
if (request.category !== undefined) updateData.category = request.category;
|
||||||
|
if (request.iconName !== undefined) updateData.icon_name = request.iconName;
|
||||||
|
if (request.defaultSize !== undefined)
|
||||||
|
updateData.default_size = safeJSONStringify(request.defaultSize) as any;
|
||||||
|
if (request.layoutConfig !== undefined)
|
||||||
|
updateData.layout_config = safeJSONStringify(request.layoutConfig) as any;
|
||||||
|
if (request.zonesConfig !== undefined)
|
||||||
|
updateData.zones_config = safeJSONStringify(request.zonesConfig) as any;
|
||||||
|
if (request.isPublic !== undefined)
|
||||||
|
updateData.is_public = request.isPublic ? "Y" : "N";
|
||||||
|
|
||||||
|
const updated = await prisma.layout_standards.update({
|
||||||
|
where: { layout_code: request.layoutCode },
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapToLayoutStandard(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 삭제 (소프트 삭제)
|
||||||
|
*/
|
||||||
|
async deleteLayout(
|
||||||
|
layoutCode: string,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const existing = await prisma.layout_standards.findFirst({
|
||||||
|
where: {
|
||||||
|
layout_code: layoutCode,
|
||||||
|
company_code: companyCode,
|
||||||
|
is_active: "Y",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new Error("레이아웃을 찾을 수 없거나 삭제 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.layout_standards.update({
|
||||||
|
where: { layout_code: layoutCode },
|
||||||
|
data: {
|
||||||
|
is_active: "N",
|
||||||
|
updated_by: userId,
|
||||||
|
updated_date: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 복제
|
||||||
|
*/
|
||||||
|
async duplicateLayout(
|
||||||
|
layoutCode: string,
|
||||||
|
newName: string,
|
||||||
|
companyCode: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<LayoutStandard> {
|
||||||
|
const original = await this.getLayoutById(layoutCode, companyCode);
|
||||||
|
if (!original) {
|
||||||
|
throw new Error("복제할 레이아웃을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateRequest: CreateLayoutRequest = {
|
||||||
|
layoutName: newName,
|
||||||
|
layoutNameEng: original.layoutNameEng
|
||||||
|
? `${original.layoutNameEng} Copy`
|
||||||
|
: undefined,
|
||||||
|
description: original.description,
|
||||||
|
layoutType: original.layoutType,
|
||||||
|
category: original.category,
|
||||||
|
iconName: original.iconName,
|
||||||
|
defaultSize: original.defaultSize,
|
||||||
|
layoutConfig: original.layoutConfig,
|
||||||
|
zonesConfig: original.zonesConfig,
|
||||||
|
isPublic: false, // 복제본은 비공개로 시작
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createLayout(duplicateRequest, companyCode, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 레이아웃 개수 조회
|
||||||
|
*/
|
||||||
|
async getLayoutCountsByCategory(
|
||||||
|
companyCode: string
|
||||||
|
): Promise<Record<string, number>> {
|
||||||
|
const counts = await prisma.layout_standards.groupBy({
|
||||||
|
by: ["category"],
|
||||||
|
_count: {
|
||||||
|
layout_code: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
is_active: "Y",
|
||||||
|
OR: [{ company_code: companyCode }, { is_public: "Y" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return counts.reduce(
|
||||||
|
(acc: Record<string, number>, item: any) => {
|
||||||
|
acc[item.category] = item._count.layout_code;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 코드 자동 생성
|
||||||
|
*/
|
||||||
|
private async generateLayoutCode(
|
||||||
|
layoutType: string,
|
||||||
|
companyCode: string
|
||||||
|
): Promise<string> {
|
||||||
|
const prefix = `${layoutType.toUpperCase()}_${companyCode}`;
|
||||||
|
const existingCodes = await prisma.layout_standards.findMany({
|
||||||
|
where: {
|
||||||
|
layout_code: {
|
||||||
|
startsWith: prefix,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
layout_code: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxNumber = existingCodes.reduce((max: number, item: any) => {
|
||||||
|
const match = item.layout_code.match(/_(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const number = parseInt(match[1], 10);
|
||||||
|
return Math.max(max, number);
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return `${prefix}_${String(maxNumber + 1).padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터베이스 모델을 LayoutStandard 타입으로 변환
|
||||||
|
*/
|
||||||
|
private mapToLayoutStandard(layout: any): LayoutStandard {
|
||||||
|
return {
|
||||||
|
layoutCode: layout.layout_code,
|
||||||
|
layoutName: layout.layout_name,
|
||||||
|
layoutNameEng: layout.layout_name_eng,
|
||||||
|
description: layout.description,
|
||||||
|
layoutType: layout.layout_type,
|
||||||
|
category: layout.category,
|
||||||
|
iconName: layout.icon_name,
|
||||||
|
defaultSize: layout.default_size,
|
||||||
|
layoutConfig: layout.layout_config,
|
||||||
|
zonesConfig: layout.zones_config,
|
||||||
|
previewImage: layout.preview_image,
|
||||||
|
sortOrder: layout.sort_order,
|
||||||
|
isActive: layout.is_active,
|
||||||
|
isPublic: layout.is_public,
|
||||||
|
companyCode: layout.company_code,
|
||||||
|
createdDate: layout.created_date,
|
||||||
|
createdBy: layout.created_by,
|
||||||
|
updatedDate: layout.updated_date,
|
||||||
|
updatedBy: layout.updated_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const layoutService = new LayoutService();
|
||||||
|
|
@ -0,0 +1,198 @@
|
||||||
|
// 레이아웃 관련 타입 정의
|
||||||
|
|
||||||
|
// 레이아웃 타입
|
||||||
|
export type LayoutType =
|
||||||
|
| "grid"
|
||||||
|
| "flexbox"
|
||||||
|
| "split"
|
||||||
|
| "card"
|
||||||
|
| "tabs"
|
||||||
|
| "accordion"
|
||||||
|
| "sidebar"
|
||||||
|
| "header-footer"
|
||||||
|
| "three-column"
|
||||||
|
| "dashboard"
|
||||||
|
| "form"
|
||||||
|
| "table"
|
||||||
|
| "custom";
|
||||||
|
|
||||||
|
// 레이아웃 카테고리
|
||||||
|
export type LayoutCategory =
|
||||||
|
| "basic"
|
||||||
|
| "form"
|
||||||
|
| "table"
|
||||||
|
| "dashboard"
|
||||||
|
| "navigation"
|
||||||
|
| "content"
|
||||||
|
| "business";
|
||||||
|
|
||||||
|
// 레이아웃 존 정의
|
||||||
|
export interface LayoutZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: {
|
||||||
|
row?: number;
|
||||||
|
column?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
};
|
||||||
|
size: {
|
||||||
|
width: number | string;
|
||||||
|
height: number | string;
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
};
|
||||||
|
style?: Record<string, any>;
|
||||||
|
allowedComponents?: string[];
|
||||||
|
isResizable?: boolean;
|
||||||
|
isRequired?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 설정
|
||||||
|
export interface LayoutConfig {
|
||||||
|
grid?: {
|
||||||
|
rows: number;
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
rowGap?: number;
|
||||||
|
columnGap?: number;
|
||||||
|
autoRows?: string;
|
||||||
|
autoColumns?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
flexbox?: {
|
||||||
|
direction: "row" | "column" | "row-reverse" | "column-reverse";
|
||||||
|
justify:
|
||||||
|
| "flex-start"
|
||||||
|
| "flex-end"
|
||||||
|
| "center"
|
||||||
|
| "space-between"
|
||||||
|
| "space-around"
|
||||||
|
| "space-evenly";
|
||||||
|
align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
|
||||||
|
wrap: "nowrap" | "wrap" | "wrap-reverse";
|
||||||
|
gap: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
split?: {
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
ratio: number[];
|
||||||
|
minSize: number[];
|
||||||
|
resizable: boolean;
|
||||||
|
splitterSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
tabs?: {
|
||||||
|
position: "top" | "bottom" | "left" | "right";
|
||||||
|
variant: "default" | "pills" | "underline";
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
defaultTab: string;
|
||||||
|
closable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
accordion?: {
|
||||||
|
multiple: boolean;
|
||||||
|
defaultExpanded: string[];
|
||||||
|
collapsible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
sidebar?: {
|
||||||
|
position: "left" | "right";
|
||||||
|
width: number | string;
|
||||||
|
collapsible: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
overlay: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
headerFooter?: {
|
||||||
|
headerHeight: number | string;
|
||||||
|
footerHeight: number | string;
|
||||||
|
stickyHeader: boolean;
|
||||||
|
stickyFooter: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
dashboard?: {
|
||||||
|
columns: number;
|
||||||
|
rowHeight: number;
|
||||||
|
margin: [number, number];
|
||||||
|
padding: [number, number];
|
||||||
|
isDraggable: boolean;
|
||||||
|
isResizable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
custom?: {
|
||||||
|
cssProperties: Record<string, string>;
|
||||||
|
className: string;
|
||||||
|
template: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 표준 정의
|
||||||
|
export interface LayoutStandard {
|
||||||
|
layoutCode: string;
|
||||||
|
layoutName: string;
|
||||||
|
layoutNameEng?: string;
|
||||||
|
description?: string;
|
||||||
|
layoutType: LayoutType;
|
||||||
|
category: LayoutCategory;
|
||||||
|
iconName?: string;
|
||||||
|
defaultSize?: { width: number; height: number };
|
||||||
|
layoutConfig: LayoutConfig;
|
||||||
|
zonesConfig: LayoutZone[];
|
||||||
|
previewImage?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: string;
|
||||||
|
isPublic?: string;
|
||||||
|
companyCode: string;
|
||||||
|
createdDate?: Date;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedDate?: Date;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 생성 요청
|
||||||
|
export interface CreateLayoutRequest {
|
||||||
|
layoutName: string;
|
||||||
|
layoutNameEng?: string;
|
||||||
|
description?: string;
|
||||||
|
layoutType: LayoutType;
|
||||||
|
category: LayoutCategory;
|
||||||
|
iconName?: string;
|
||||||
|
defaultSize?: { width: number; height: number };
|
||||||
|
layoutConfig: LayoutConfig;
|
||||||
|
zonesConfig: LayoutZone[];
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 수정 요청
|
||||||
|
export interface UpdateLayoutRequest extends Partial<CreateLayoutRequest> {
|
||||||
|
layoutCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 목록 조회 요청
|
||||||
|
export interface GetLayoutsRequest {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
category?: LayoutCategory;
|
||||||
|
layoutType?: LayoutType;
|
||||||
|
searchTerm?: string;
|
||||||
|
includePublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 목록 응답
|
||||||
|
export interface GetLayoutsResponse {
|
||||||
|
data: LayoutStandard[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 복제 요청
|
||||||
|
export interface DuplicateLayoutRequest {
|
||||||
|
layoutCode: string;
|
||||||
|
newName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { LayoutFormModal } from "@/components/admin/LayoutFormModal";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
MoreHorizontal,
|
||||||
|
Edit,
|
||||||
|
Copy,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
Grid,
|
||||||
|
Layout,
|
||||||
|
LayoutDashboard,
|
||||||
|
Table,
|
||||||
|
Navigation,
|
||||||
|
FileText,
|
||||||
|
Building,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { LayoutStandard, LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout";
|
||||||
|
import { layoutApi } from "@/lib/api/layout";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// 코드 레벨 레이아웃 타입
|
||||||
|
interface CodeLayout {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameEng?: string;
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
type: "code";
|
||||||
|
isActive: boolean;
|
||||||
|
tags: string[];
|
||||||
|
metadata?: any;
|
||||||
|
zones: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 아이콘 매핑
|
||||||
|
const CATEGORY_ICONS = {
|
||||||
|
basic: Grid,
|
||||||
|
form: FileText,
|
||||||
|
table: Table,
|
||||||
|
dashboard: LayoutDashboard,
|
||||||
|
navigation: Navigation,
|
||||||
|
content: Layout,
|
||||||
|
business: Building,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 이름 매핑
|
||||||
|
const CATEGORY_NAMES = {
|
||||||
|
basic: "기본",
|
||||||
|
form: "폼",
|
||||||
|
table: "테이블",
|
||||||
|
dashboard: "대시보드",
|
||||||
|
navigation: "네비게이션",
|
||||||
|
content: "컨텐츠",
|
||||||
|
business: "업무용",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LayoutManagementPage() {
|
||||||
|
const [layouts, setLayouts] = useState<LayoutStandard[]>([]);
|
||||||
|
const [codeLayouts, setCodeLayouts] = useState<CodeLayout[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [codeLoading, setCodeLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [activeTab, setActiveTab] = useState("db");
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [layoutToDelete, setLayoutToDelete] = useState<LayoutStandard | null>(null);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리별 개수
|
||||||
|
const [categoryCounts, setCategoryCounts] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
// 레이아웃 목록 로드
|
||||||
|
const loadLayouts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const params = {
|
||||||
|
page: currentPage,
|
||||||
|
size: 20,
|
||||||
|
searchTerm: searchTerm || undefined,
|
||||||
|
category: selectedCategory !== "all" ? (selectedCategory as LayoutCategory) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await layoutApi.getLayouts(params);
|
||||||
|
setLayouts(response.data);
|
||||||
|
setTotalPages(response.totalPages);
|
||||||
|
setTotal(response.total);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 목록 조회 실패:", error);
|
||||||
|
toast.error("레이아웃 목록을 불러오는데 실패했습니다.");
|
||||||
|
setLayouts([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리별 개수 로드
|
||||||
|
const loadCategoryCounts = async () => {
|
||||||
|
try {
|
||||||
|
const counts = await layoutApi.getLayoutCountsByCategory();
|
||||||
|
setCategoryCounts(counts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 개수 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 코드 레벨 레이아웃 로드
|
||||||
|
const loadCodeLayouts = async () => {
|
||||||
|
try {
|
||||||
|
setCodeLoading(true);
|
||||||
|
const response = await fetch("/api/admin/layouts/list");
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setCodeLayouts(result.data.codeLayouts);
|
||||||
|
} else {
|
||||||
|
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
|
||||||
|
setCodeLayouts([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("코드 레이아웃 조회 실패:", error);
|
||||||
|
toast.error("코드 레이아웃 목록을 불러오는데 실패했습니다.");
|
||||||
|
setCodeLayouts([]);
|
||||||
|
} finally {
|
||||||
|
setCodeLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadLayouts();
|
||||||
|
}, [currentPage, selectedCategory]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadCategoryCounts();
|
||||||
|
loadCodeLayouts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
const handleSearch = () => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
loadLayouts();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엔터키 검색
|
||||||
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 삭제
|
||||||
|
const handleDelete = async (layout: LayoutStandard) => {
|
||||||
|
setLayoutToDelete(layout);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!layoutToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await layoutApi.deleteLayout(layoutToDelete.layoutCode);
|
||||||
|
toast.success("레이아웃이 삭제되었습니다.");
|
||||||
|
loadLayouts();
|
||||||
|
loadCategoryCounts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 삭제 실패:", error);
|
||||||
|
toast.error("레이아웃 삭제에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setLayoutToDelete(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 복제
|
||||||
|
const handleDuplicate = async (layout: LayoutStandard) => {
|
||||||
|
try {
|
||||||
|
const newName = `${layout.layoutName} (복사)`;
|
||||||
|
await layoutApi.duplicateLayout(layout.layoutCode, { newName });
|
||||||
|
toast.success("레이아웃이 복제되었습니다.");
|
||||||
|
loadLayouts();
|
||||||
|
loadCategoryCounts();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 복제 실패:", error);
|
||||||
|
toast.error("레이아웃 복제에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
setCurrentPage(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="mb-6 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">레이아웃 관리</h1>
|
||||||
|
<p className="text-gray-600">화면 레이아웃을 생성하고 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<Button className="flex items-center gap-2" onClick={() => setCreateModalOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />새 레이아웃
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 및 필터 */}
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="레이아웃 이름 또는 설명으로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyPress={handleKeyPress}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleSearch}>검색</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 카테고리 탭 */}
|
||||||
|
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="mb-6">
|
||||||
|
<TabsList className="grid w-full grid-cols-8">
|
||||||
|
<TabsTrigger value="all" className="flex items-center gap-2">
|
||||||
|
전체 ({total})
|
||||||
|
</TabsTrigger>
|
||||||
|
{Object.entries(LAYOUT_CATEGORIES).map(([key, value]) => {
|
||||||
|
const Icon = CATEGORY_ICONS[value as keyof typeof CATEGORY_ICONS];
|
||||||
|
const count = categoryCounts[value] || 0;
|
||||||
|
return (
|
||||||
|
<TabsTrigger key={key} value={value} className="flex items-center gap-2">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{CATEGORY_NAMES[value as keyof typeof CATEGORY_NAMES]} ({count})
|
||||||
|
</TabsTrigger>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-8 text-center">로딩 중...</div>
|
||||||
|
) : layouts.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-gray-500">레이아웃이 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 레이아웃 그리드 */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{layouts.map((layout) => {
|
||||||
|
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
|
||||||
|
return (
|
||||||
|
<Card key={layout.layoutCode} className="transition-shadow hover:shadow-lg">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CategoryIcon className="h-5 w-5 text-gray-600" />
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
|
미리보기
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Edit className="mr-2 h-4 w-4" />
|
||||||
|
편집
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleDuplicate(layout)}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
복제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleDelete(layout)} className="text-red-600">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
삭제
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-lg">{layout.layoutName}</CardTitle>
|
||||||
|
{layout.description && (
|
||||||
|
<p className="line-clamp-2 text-sm text-gray-600">{layout.description}</p>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">타입:</span>
|
||||||
|
<Badge variant="outline">{layout.layoutType}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-500">존 개수:</span>
|
||||||
|
<span>{layout.zonesConfig.length}개</span>
|
||||||
|
</div>
|
||||||
|
{layout.isPublic === "Y" && (
|
||||||
|
<Badge variant="default" className="w-full justify-center">
|
||||||
|
공개 레이아웃
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-8 flex justify-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
|
<Button
|
||||||
|
key={page}
|
||||||
|
variant={currentPage === page ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(page)}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>레이아웃 삭제</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
정말로 "{layoutToDelete?.layoutName}" 레이아웃을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
|
||||||
|
삭제
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 새 레이아웃 생성 모달 */}
|
||||||
|
<LayoutFormModal
|
||||||
|
open={createModalOpen}
|
||||||
|
onOpenChange={setCreateModalOpen}
|
||||||
|
onSuccess={() => {
|
||||||
|
loadLayouts();
|
||||||
|
loadCategoryCounts();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { exec } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { command, layoutData } = await request.json();
|
||||||
|
|
||||||
|
if (!command || !layoutData) {
|
||||||
|
return NextResponse.json({ success: false, message: "명령어와 레이아웃 데이터가 필요합니다." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 프론트엔드 디렉토리 경로
|
||||||
|
const frontendDir = path.join(process.cwd());
|
||||||
|
|
||||||
|
// CLI 명령어 실행
|
||||||
|
const fullCommand = `cd ${frontendDir} && node scripts/create-layout.js ${command}`;
|
||||||
|
|
||||||
|
console.log("실행할 명령어:", fullCommand);
|
||||||
|
|
||||||
|
const { stdout, stderr } = await execAsync(fullCommand);
|
||||||
|
|
||||||
|
if (stderr && !stderr.includes("warning")) {
|
||||||
|
console.error("CLI 실행 오류:", stderr);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 생성 중 오류가 발생했습니다.",
|
||||||
|
error: stderr,
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 생성된 파일들 확인
|
||||||
|
const layoutId = layoutData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
||||||
|
const layoutDir = path.join(frontendDir, "lib/registry/layouts", layoutId);
|
||||||
|
|
||||||
|
const generatedFiles: string[] = [];
|
||||||
|
|
||||||
|
if (fs.existsSync(layoutDir)) {
|
||||||
|
const files = fs.readdirSync(layoutDir);
|
||||||
|
files.forEach((file) => {
|
||||||
|
generatedFiles.push(`layouts/${layoutId}/${file}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록을 위해 index.ts 업데이트
|
||||||
|
await updateLayoutIndex(layoutId);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: "레이아웃이 성공적으로 생성되었습니다.",
|
||||||
|
files: generatedFiles,
|
||||||
|
output: stdout,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 생성 API 오류:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "서버 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* layouts/index.ts에 새 레이아웃 import 추가
|
||||||
|
*/
|
||||||
|
async function updateLayoutIndex(layoutId: string) {
|
||||||
|
try {
|
||||||
|
const indexPath = path.join(process.cwd(), "lib/registry/layouts/index.ts");
|
||||||
|
|
||||||
|
if (!fs.existsSync(indexPath)) {
|
||||||
|
console.warn("layouts/index.ts 파일을 찾을 수 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs.readFileSync(indexPath, "utf8");
|
||||||
|
|
||||||
|
// 새 import 문 추가
|
||||||
|
const newImport = `import "./${layoutId}/${layoutId.charAt(0).toUpperCase() + layoutId.slice(1)}LayoutRenderer";`;
|
||||||
|
|
||||||
|
// 이미 import되어 있는지 확인
|
||||||
|
if (content.includes(newImport)) {
|
||||||
|
console.log("이미 import되어 있습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 import 문들 찾기
|
||||||
|
const importRegex = /import "\.\/.+\/\w+LayoutRenderer";/g;
|
||||||
|
const imports = content.match(importRegex) || [];
|
||||||
|
|
||||||
|
if (imports.length > 0) {
|
||||||
|
// 마지막 import 뒤에 추가
|
||||||
|
const lastImport = imports[imports.length - 1];
|
||||||
|
const lastImportIndex = content.lastIndexOf(lastImport);
|
||||||
|
const insertPosition = lastImportIndex + lastImport.length;
|
||||||
|
|
||||||
|
content = content.slice(0, insertPosition) + "\n" + newImport + content.slice(insertPosition);
|
||||||
|
} else {
|
||||||
|
// import가 없다면 파일 시작 부분에 추가
|
||||||
|
const newStructureComment = "// 새 구조 레이아웃들 (자동 등록)";
|
||||||
|
const commentIndex = content.indexOf(newStructureComment);
|
||||||
|
|
||||||
|
if (commentIndex !== -1) {
|
||||||
|
const insertPosition = content.indexOf("\n", commentIndex) + 1;
|
||||||
|
content = content.slice(0, insertPosition) + newImport + "\n" + content.slice(insertPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(indexPath, content);
|
||||||
|
console.log(`layouts/index.ts에 ${layoutId} import 추가 완료`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("index.ts 업데이트 오류:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { LayoutRegistry } from "@/lib/registry/LayoutRegistry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 등록된 레이아웃 목록 조회 (코드 레벨 + DB)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 코드 레벨에서 등록된 레이아웃들
|
||||||
|
const codeLayouts = LayoutRegistry.getAllLayouts().map((layout) => ({
|
||||||
|
id: layout.id,
|
||||||
|
name: layout.name,
|
||||||
|
nameEng: layout.nameEng,
|
||||||
|
description: layout.description,
|
||||||
|
category: layout.category,
|
||||||
|
type: "code", // 코드로 생성된 레이아웃
|
||||||
|
isActive: layout.isActive !== false,
|
||||||
|
tags: layout.tags || [],
|
||||||
|
metadata: layout.metadata,
|
||||||
|
zones: layout.defaultZones?.length || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 레지스트리 통계
|
||||||
|
const registryInfo = LayoutRegistry.getRegistryInfo();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
codeLayouts,
|
||||||
|
statistics: {
|
||||||
|
total: registryInfo.totalLayouts,
|
||||||
|
active: registryInfo.activeLayouts,
|
||||||
|
categories: registryInfo.categoryCounts,
|
||||||
|
types: registryInfo.registeredTypes,
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
codeLayoutCount: codeLayouts.length,
|
||||||
|
activeCodeLayouts: codeLayouts.filter((l) => l.isActive).length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 목록 조회 오류:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
message: "레이아웃 목록 조회에 실패했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,534 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
Grid,
|
||||||
|
Layout,
|
||||||
|
Navigation,
|
||||||
|
Building,
|
||||||
|
FileText,
|
||||||
|
Table,
|
||||||
|
LayoutDashboard,
|
||||||
|
Plus,
|
||||||
|
Minus,
|
||||||
|
Info,
|
||||||
|
Wand2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { LayoutCategory } from "@/types/layout";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface LayoutFormModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리 정의
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ id: "basic", name: "기본", icon: Grid, description: "그리드, 플렉스박스 등 기본 레이아웃" },
|
||||||
|
{ id: "navigation", name: "네비게이션", icon: Navigation, description: "메뉴, 탭, 아코디언 등" },
|
||||||
|
{ id: "business", name: "비즈니스", icon: Building, description: "대시보드, 차트, 리포트 등" },
|
||||||
|
{ id: "form", name: "폼", icon: FileText, description: "입력 폼, 설정 패널 등" },
|
||||||
|
{ id: "table", name: "테이블", icon: Table, description: "데이터 테이블, 목록 등" },
|
||||||
|
{ id: "dashboard", name: "대시보드", icon: LayoutDashboard, description: "위젯, 카드 레이아웃 등" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// 레이아웃 템플릿 정의
|
||||||
|
const LAYOUT_TEMPLATES = [
|
||||||
|
{
|
||||||
|
id: "2-column",
|
||||||
|
name: "2열 레이아웃",
|
||||||
|
description: "좌우 2개 영역으로 구성",
|
||||||
|
zones: 2,
|
||||||
|
example: "사이드바 + 메인 콘텐츠",
|
||||||
|
icon: "▢ ▢",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3-column",
|
||||||
|
name: "3열 레이아웃",
|
||||||
|
description: "좌측, 중앙, 우측 3개 영역",
|
||||||
|
zones: 3,
|
||||||
|
example: "네비 + 콘텐츠 + 사이드",
|
||||||
|
icon: "▢ ▢ ▢",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "header-content",
|
||||||
|
name: "헤더-콘텐츠",
|
||||||
|
description: "상단 헤더 + 하단 콘텐츠",
|
||||||
|
zones: 2,
|
||||||
|
example: "제목 + 내용 영역",
|
||||||
|
icon: "▬\n▢",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "card-grid",
|
||||||
|
name: "카드 그리드",
|
||||||
|
description: "2x2 카드 격자 구조",
|
||||||
|
zones: 4,
|
||||||
|
example: "대시보드, 통계 패널",
|
||||||
|
icon: "▢▢\n▢▢",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accordion",
|
||||||
|
name: "아코디언",
|
||||||
|
description: "접고 펼칠 수 있는 섹션들",
|
||||||
|
zones: 3,
|
||||||
|
example: "FAQ, 설정 패널",
|
||||||
|
icon: "▷ ▽ ▷",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tabs",
|
||||||
|
name: "탭 레이아웃",
|
||||||
|
description: "탭으로 구성된 다중 패널",
|
||||||
|
zones: 3,
|
||||||
|
example: "설정, 상세 정보",
|
||||||
|
icon: "[Tab1][Tab2][Tab3]",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenChange, onSuccess }) => {
|
||||||
|
const [step, setStep] = useState<"basic" | "template" | "advanced">("basic");
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
nameEng: "",
|
||||||
|
description: "",
|
||||||
|
category: "" as LayoutCategory | "",
|
||||||
|
zones: 2,
|
||||||
|
template: "",
|
||||||
|
author: "Developer",
|
||||||
|
});
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [generationResult, setGenerationResult] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
files?: string[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setStep("basic");
|
||||||
|
setFormData({
|
||||||
|
name: "",
|
||||||
|
nameEng: "",
|
||||||
|
description: "",
|
||||||
|
category: "",
|
||||||
|
zones: 2,
|
||||||
|
template: "",
|
||||||
|
author: "Developer",
|
||||||
|
});
|
||||||
|
setGenerationResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
handleReset();
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (step === "basic") {
|
||||||
|
setStep("template");
|
||||||
|
} else if (step === "template") {
|
||||||
|
setStep("advanced");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (step === "template") {
|
||||||
|
setStep("basic");
|
||||||
|
} else if (step === "advanced") {
|
||||||
|
setStep("template");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateBasic = () => {
|
||||||
|
return formData.name.trim() && formData.category && formData.description.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateTemplate = () => {
|
||||||
|
return formData.template && formData.zones > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateLayout = async () => {
|
||||||
|
try {
|
||||||
|
setIsGenerating(true);
|
||||||
|
|
||||||
|
// CLI 명령어 구성
|
||||||
|
const command = [
|
||||||
|
formData.name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
||||||
|
`--category=${formData.category}`,
|
||||||
|
`--zones=${formData.zones}`,
|
||||||
|
`--description="${formData.description}"`,
|
||||||
|
formData.author !== "Developer" ? `--author="${formData.author}"` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
// API 호출로 CLI 명령어 실행
|
||||||
|
const response = await fetch("/api/admin/layouts/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
command,
|
||||||
|
layoutData: formData,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setGenerationResult({
|
||||||
|
success: true,
|
||||||
|
message: "레이아웃이 성공적으로 생성되었습니다!",
|
||||||
|
files: result.files || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("레이아웃 생성 완료");
|
||||||
|
|
||||||
|
// 3초 후 자동으로 모달 닫고 새로고침
|
||||||
|
setTimeout(() => {
|
||||||
|
handleClose();
|
||||||
|
onSuccess();
|
||||||
|
}, 3000);
|
||||||
|
} else {
|
||||||
|
setGenerationResult({
|
||||||
|
success: false,
|
||||||
|
message: result.message || "레이아웃 생성에 실패했습니다.",
|
||||||
|
});
|
||||||
|
toast.error("레이아웃 생성 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 생성 오류:", error);
|
||||||
|
setGenerationResult({
|
||||||
|
success: false,
|
||||||
|
message: "서버 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
toast.error("서버 오류");
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Wand2 className="h-5 w-5" />새 레이아웃 생성
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>GUI를 통해 새로운 레이아웃을 쉽게 생성할 수 있습니다.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 단계 표시기 */}
|
||||||
|
<div className="mb-6 flex items-center justify-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 ${step === "basic" ? "text-blue-600" : step === "template" || step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "basic" ? "bg-blue-100 text-blue-600" : step === "template" || step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">기본 정보</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-8 bg-gray-300" />
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 ${step === "template" ? "text-blue-600" : step === "advanced" ? "text-green-600" : "text-gray-400"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "template" ? "bg-blue-100 text-blue-600" : step === "advanced" ? "bg-green-100 text-green-600" : "bg-gray-100"}`}
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">템플릿 선택</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-px w-8 bg-gray-300" />
|
||||||
|
<div className={`flex items-center gap-2 ${step === "advanced" ? "text-blue-600" : "text-gray-400"}`}>
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${step === "advanced" ? "bg-blue-100 text-blue-600" : "bg-gray-100"}`}
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">고급 설정</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 단계별 컨텐츠 */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{step === "basic" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">레이아웃 이름 *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="예: 사이드바, 대시보드, 카드그리드"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="nameEng">영문 이름</Label>
|
||||||
|
<Input
|
||||||
|
id="nameEng"
|
||||||
|
placeholder="예: Sidebar, Dashboard, CardGrid"
|
||||||
|
value={formData.nameEng}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, nameEng: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>카테고리 *</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{CATEGORIES.map((category) => {
|
||||||
|
const IconComponent = category.icon;
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={category.id}
|
||||||
|
className={`cursor-pointer transition-all ${
|
||||||
|
formData.category === category.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setFormData((prev) => ({ ...prev, category: category.id }))}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<IconComponent className="h-5 w-5 text-gray-600" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{category.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{category.description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명 *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="레이아웃의 용도와 특징을 설명해주세요..."
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "template" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>레이아웃 템플릿 *</Label>
|
||||||
|
<p className="mb-3 text-sm text-gray-500">원하는 레이아웃 구조를 선택하세요</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{LAYOUT_TEMPLATES.map((template) => (
|
||||||
|
<Card
|
||||||
|
key={template.id}
|
||||||
|
className={`cursor-pointer transition-all ${
|
||||||
|
formData.template === template.id ? "bg-blue-50 ring-2 ring-blue-500" : "hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
template: template.id,
|
||||||
|
zones: template.zones,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="font-medium">{template.name}</div>
|
||||||
|
<Badge variant="secondary">{template.zones}개 영역</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600">{template.description}</div>
|
||||||
|
<div className="text-xs text-gray-500">예: {template.example}</div>
|
||||||
|
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="zones">영역 개수</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
zones: Math.max(1, prev.zones - 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={formData.zones <= 1}
|
||||||
|
>
|
||||||
|
<Minus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id="zones"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
value={formData.zones}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
zones: parseInt(e.target.value) || 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="w-20 text-center"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
zones: Math.min(10, prev.zones + 1),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={formData.zones >= 10}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm text-gray-500">개 영역</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "advanced" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{generationResult ? (
|
||||||
|
<Alert
|
||||||
|
className={generationResult.success ? "border-green-200 bg-green-50" : "border-red-200 bg-red-50"}
|
||||||
|
>
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
|
||||||
|
{generationResult.message}
|
||||||
|
{generationResult.success && generationResult.files && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-sm font-medium">생성된 파일들:</div>
|
||||||
|
<ul className="mt-1 space-y-1 text-xs">
|
||||||
|
{generationResult.files.map((file, index) => (
|
||||||
|
<li key={index} className="text-green-700">
|
||||||
|
• {file}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="author">작성자</Label>
|
||||||
|
<Input
|
||||||
|
id="author"
|
||||||
|
value={formData.author}
|
||||||
|
onChange={(e) => setFormData((prev) => ({ ...prev, author: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm">생성 미리보기</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<strong>이름:</strong> {formData.name || "이름 없음"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>카테고리:</strong>{" "}
|
||||||
|
{CATEGORIES.find((c) => c.id === formData.category)?.name || "선택 안됨"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>템플릿:</strong>{" "}
|
||||||
|
{LAYOUT_TEMPLATES.find((t) => t.id === formData.template)?.name || "선택 안됨"}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>영역 개수:</strong> {formData.zones}개
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>생성될 파일:</strong>
|
||||||
|
</div>
|
||||||
|
<ul className="ml-4 space-y-1 text-xs text-gray-600">
|
||||||
|
<li>• {formData.name.toLowerCase()}/index.ts</li>
|
||||||
|
<li>
|
||||||
|
• {formData.name.toLowerCase()}/{formData.name}Layout.tsx
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
• {formData.name.toLowerCase()}/{formData.name}LayoutRenderer.tsx
|
||||||
|
</li>
|
||||||
|
<li>• {formData.name.toLowerCase()}/config.ts</li>
|
||||||
|
<li>• {formData.name.toLowerCase()}/types.ts</li>
|
||||||
|
<li>• {formData.name.toLowerCase()}/README.md</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-2">
|
||||||
|
{step !== "basic" && !generationResult && (
|
||||||
|
<Button variant="outline" onClick={handleBack}>
|
||||||
|
이전
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "basic" && (
|
||||||
|
<Button onClick={handleNext} disabled={!validateBasic()}>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "template" && (
|
||||||
|
<Button onClick={handleNext} disabled={!validateTemplate()}>
|
||||||
|
다음
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "advanced" && !generationResult && (
|
||||||
|
<Button onClick={generateLayout} disabled={isGenerating}>
|
||||||
|
{isGenerating ? "생성 중..." : "레이아웃 생성"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={handleClose}>
|
||||||
|
{generationResult?.success ? "완료" : "취소"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Cog,
|
Cog,
|
||||||
Layout,
|
Layout,
|
||||||
Monitor,
|
Monitor,
|
||||||
|
Square,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -33,6 +34,8 @@ interface DesignerToolbarProps {
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
isSaving?: boolean;
|
isSaving?: boolean;
|
||||||
|
showZoneBorders?: boolean;
|
||||||
|
onToggleZoneBorders?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
|
|
@ -48,6 +51,8 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
|
showZoneBorders = true,
|
||||||
|
onToggleZoneBorders,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
<div className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 shadow-sm">
|
||||||
|
|
@ -154,6 +159,23 @@ export const DesignerToolbar: React.FC<DesignerToolbarProps> = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* 구역 경계 표시 토글 버튼 */}
|
||||||
|
{onToggleZoneBorders && (
|
||||||
|
<Button
|
||||||
|
variant={showZoneBorders ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleZoneBorders}
|
||||||
|
className={cn("flex items-center space-x-2", showZoneBorders && "bg-green-600 text-white")}
|
||||||
|
title="구역 경계 표시/숨김"
|
||||||
|
>
|
||||||
|
<Square className="h-4 w-4" />
|
||||||
|
<span>구역</span>
|
||||||
|
<Badge variant="secondary" className="ml-1 text-xs">
|
||||||
|
Z
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
|
variant={panelStates.detailSettings?.isOpen ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
|
||||||
|
|
@ -46,12 +46,16 @@ import DesignerToolbar from "./DesignerToolbar";
|
||||||
import TablesPanel from "./panels/TablesPanel";
|
import TablesPanel from "./panels/TablesPanel";
|
||||||
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
import { TemplatesPanel, TemplateComponent } from "./panels/TemplatesPanel";
|
||||||
import ComponentsPanel from "./panels/ComponentsPanel";
|
import ComponentsPanel from "./panels/ComponentsPanel";
|
||||||
|
import LayoutsPanel from "./panels/LayoutsPanel";
|
||||||
import PropertiesPanel from "./panels/PropertiesPanel";
|
import PropertiesPanel from "./panels/PropertiesPanel";
|
||||||
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
import DetailSettingsPanel from "./panels/DetailSettingsPanel";
|
||||||
import GridPanel from "./panels/GridPanel";
|
import GridPanel from "./panels/GridPanel";
|
||||||
import ResolutionPanel from "./panels/ResolutionPanel";
|
import ResolutionPanel from "./panels/ResolutionPanel";
|
||||||
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
import { usePanelState, PanelConfig } from "@/hooks/usePanelState";
|
||||||
|
|
||||||
|
// 레이아웃 초기화
|
||||||
|
import "@/lib/registry/layouts";
|
||||||
|
|
||||||
interface ScreenDesignerProps {
|
interface ScreenDesignerProps {
|
||||||
selectedScreen: ScreenDefinition | null;
|
selectedScreen: ScreenDefinition | null;
|
||||||
onBackToList: () => void;
|
onBackToList: () => void;
|
||||||
|
|
@ -75,6 +79,14 @@ const panelConfigs: PanelConfig[] = [
|
||||||
defaultHeight: 700,
|
defaultHeight: 700,
|
||||||
shortcutKey: "m", // template의 m
|
shortcutKey: "m", // template의 m
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "layouts",
|
||||||
|
title: "레이아웃",
|
||||||
|
defaultPosition: "left",
|
||||||
|
defaultWidth: 380,
|
||||||
|
defaultHeight: 700,
|
||||||
|
shortcutKey: "l", // layout의 l
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "properties",
|
id: "properties",
|
||||||
title: "속성 편집",
|
title: "속성 편집",
|
||||||
|
|
@ -1212,6 +1224,74 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
|
[layout, gridInfo, selectedScreen, snapToGrid, saveToHistory, openPanel],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 레이아웃 드래그 처리
|
||||||
|
const handleLayoutDrop = useCallback(
|
||||||
|
(e: React.DragEvent, layoutData: any) => {
|
||||||
|
const rect = canvasRef.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
|
||||||
|
const dropX = e.clientX - rect.left;
|
||||||
|
const dropY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
// 현재 해상도에 맞는 격자 정보 계산
|
||||||
|
const currentGridInfo = layout.gridSettings
|
||||||
|
? calculateGridInfo(screenResolution.width, screenResolution.height, {
|
||||||
|
columns: layout.gridSettings.columns,
|
||||||
|
gap: layout.gridSettings.gap,
|
||||||
|
padding: layout.gridSettings.padding,
|
||||||
|
snapToGrid: layout.gridSettings.snapToGrid || false,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 격자 스냅 적용
|
||||||
|
const snappedPosition =
|
||||||
|
layout.gridSettings?.snapToGrid && currentGridInfo
|
||||||
|
? snapToGrid({ x: dropX, y: dropY, z: 1 }, currentGridInfo, layout.gridSettings as GridUtilSettings)
|
||||||
|
: { x: dropX, y: dropY, z: 1 };
|
||||||
|
|
||||||
|
console.log("🏗️ 레이아웃 드롭:", {
|
||||||
|
layoutType: layoutData.layoutType,
|
||||||
|
zonesCount: layoutData.zones.length,
|
||||||
|
dropPosition: { x: dropX, y: dropY },
|
||||||
|
snappedPosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트 생성
|
||||||
|
const newLayoutComponent: ComponentData = {
|
||||||
|
id: layoutData.id,
|
||||||
|
type: "layout",
|
||||||
|
layoutType: layoutData.layoutType,
|
||||||
|
layoutConfig: layoutData.layoutConfig,
|
||||||
|
zones: layoutData.zones.map((zone: any) => ({
|
||||||
|
...zone,
|
||||||
|
id: `${layoutData.id}_${zone.id}`, // 레이아웃 ID를 접두사로 추가
|
||||||
|
})),
|
||||||
|
children: [],
|
||||||
|
position: snappedPosition,
|
||||||
|
size: layoutData.size,
|
||||||
|
label: layoutData.label,
|
||||||
|
allowedComponentTypes: layoutData.allowedComponentTypes,
|
||||||
|
dropZoneConfig: layoutData.dropZoneConfig,
|
||||||
|
} as ComponentData;
|
||||||
|
|
||||||
|
// 레이아웃에 새 컴포넌트 추가
|
||||||
|
const newLayout = {
|
||||||
|
...layout,
|
||||||
|
components: [...layout.components, newLayoutComponent],
|
||||||
|
};
|
||||||
|
|
||||||
|
setLayout(newLayout);
|
||||||
|
saveToHistory(newLayout);
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트 선택
|
||||||
|
setSelectedComponent(newLayoutComponent);
|
||||||
|
openPanel("properties");
|
||||||
|
|
||||||
|
toast.success(`${layoutData.label} 레이아웃이 추가되었습니다.`);
|
||||||
|
},
|
||||||
|
[layout, gridInfo, screenResolution, snapToGrid, saveToHistory, openPanel],
|
||||||
|
);
|
||||||
|
|
||||||
// 컴포넌트 드래그 처리
|
// 컴포넌트 드래그 처리
|
||||||
const handleComponentDrop = useCallback(
|
const handleComponentDrop = useCallback(
|
||||||
(e: React.DragEvent, component: any) => {
|
(e: React.DragEvent, component: any) => {
|
||||||
|
|
@ -1357,6 +1437,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 레이아웃 드래그인 경우
|
||||||
|
if (parsedData.type === "layout") {
|
||||||
|
handleLayoutDrop(e, parsedData.layout);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 컴포넌트 드래그인 경우
|
// 컴포넌트 드래그인 경우
|
||||||
if (parsedData.type === "component") {
|
if (parsedData.type === "component") {
|
||||||
handleComponentDrop(e, parsedData.component);
|
handleComponentDrop(e, parsedData.component);
|
||||||
|
|
@ -3129,6 +3215,27 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
/>
|
/>
|
||||||
</FloatingPanel>
|
</FloatingPanel>
|
||||||
|
|
||||||
|
<FloatingPanel
|
||||||
|
id="layouts"
|
||||||
|
title="레이아웃"
|
||||||
|
isOpen={panelStates.layouts?.isOpen || false}
|
||||||
|
onClose={() => closePanelState("layouts")}
|
||||||
|
position="left"
|
||||||
|
width={380}
|
||||||
|
height={700}
|
||||||
|
autoHeight={false}
|
||||||
|
>
|
||||||
|
<LayoutsPanel
|
||||||
|
onDragStart={(e, layoutData) => {
|
||||||
|
const dragData = {
|
||||||
|
type: "layout",
|
||||||
|
layout: layoutData,
|
||||||
|
};
|
||||||
|
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FloatingPanel>
|
||||||
|
|
||||||
<FloatingPanel
|
<FloatingPanel
|
||||||
id="components"
|
id="components"
|
||||||
title="컴포넌트"
|
title="컴포넌트"
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Grid, Layout, LayoutDashboard, Table, Navigation, FileText, Building, Search, Plus } from "lucide-react";
|
||||||
|
import { LAYOUT_CATEGORIES, LayoutCategory } from "@/types/layout";
|
||||||
|
import { LayoutRegistry } from "@/lib/registry/LayoutRegistry";
|
||||||
|
|
||||||
|
// 카테고리 아이콘 매핑
|
||||||
|
const CATEGORY_ICONS = {
|
||||||
|
basic: Grid,
|
||||||
|
form: FileText,
|
||||||
|
table: Table,
|
||||||
|
dashboard: LayoutDashboard,
|
||||||
|
navigation: Navigation,
|
||||||
|
content: Layout,
|
||||||
|
business: Building,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 이름 매핑
|
||||||
|
const CATEGORY_NAMES = {
|
||||||
|
basic: "기본",
|
||||||
|
form: "폼",
|
||||||
|
table: "테이블",
|
||||||
|
dashboard: "대시보드",
|
||||||
|
navigation: "네비게이션",
|
||||||
|
content: "컨텐츠",
|
||||||
|
business: "업무용",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LayoutsPanelProps {
|
||||||
|
onDragStart: (e: React.DragEvent, layoutData: any) => void;
|
||||||
|
onLayoutSelect?: (layoutDefinition: any) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LayoutsPanel({ onDragStart, onLayoutSelect, className }: LayoutsPanelProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||||
|
|
||||||
|
// 레지스트리에서 레이아웃 조회
|
||||||
|
const allLayouts = useMemo(() => LayoutRegistry.getAllLayouts(), []);
|
||||||
|
|
||||||
|
// 필터링된 레이아웃
|
||||||
|
const filteredLayouts = useMemo(() => {
|
||||||
|
let layouts = allLayouts;
|
||||||
|
|
||||||
|
// 카테고리 필터
|
||||||
|
if (selectedCategory !== "all") {
|
||||||
|
layouts = layouts.filter((layout) => layout.category === selectedCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
if (searchTerm) {
|
||||||
|
layouts = layouts.filter(
|
||||||
|
(layout) =>
|
||||||
|
layout.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
layout.nameEng?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
layout.description?.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return layouts;
|
||||||
|
}, [allLayouts, selectedCategory, searchTerm]);
|
||||||
|
|
||||||
|
// 카테고리별 개수
|
||||||
|
const categoryCounts = useMemo(() => {
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
Object.values(LAYOUT_CATEGORIES).forEach((category) => {
|
||||||
|
counts[category] = allLayouts.filter((layout) => layout.category === category).length;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}, [allLayouts]);
|
||||||
|
|
||||||
|
// 레이아웃 드래그 시작 핸들러
|
||||||
|
const handleDragStart = (e: React.DragEvent, layoutDefinition: any) => {
|
||||||
|
// 새 레이아웃 컴포넌트 데이터 생성
|
||||||
|
const layoutData = {
|
||||||
|
id: `layout_${Date.now()}`,
|
||||||
|
type: "layout",
|
||||||
|
layoutType: layoutDefinition.id,
|
||||||
|
layoutConfig: layoutDefinition.defaultConfig,
|
||||||
|
zones: layoutDefinition.defaultZones,
|
||||||
|
children: [],
|
||||||
|
allowedComponentTypes: [],
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: layoutDefinition.defaultSize || { width: 400, height: 300 },
|
||||||
|
label: layoutDefinition.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 데이터 설정
|
||||||
|
e.dataTransfer.setData("application/json", JSON.stringify(layoutData));
|
||||||
|
e.dataTransfer.setData("text/plain", layoutDefinition.name);
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
|
||||||
|
onDragStart(e, layoutData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 선택 핸들러
|
||||||
|
const handleLayoutSelect = (layoutDefinition: any) => {
|
||||||
|
onLayoutSelect?.(layoutDefinition);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`layouts-panel h-full ${className || ""}`}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">레이아웃</h3>
|
||||||
|
<Button size="sm" variant="outline">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="레이아웃 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 탭 */}
|
||||||
|
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="flex-1">
|
||||||
|
<TabsList className="grid w-full grid-cols-4 px-4 pt-2">
|
||||||
|
<TabsTrigger value="all" className="text-xs">
|
||||||
|
전체 ({allLayouts.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="basic" className="text-xs">
|
||||||
|
기본 ({categoryCounts.basic || 0})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="form" className="text-xs">
|
||||||
|
폼 ({categoryCounts.form || 0})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="navigation" className="text-xs">
|
||||||
|
탭 ({categoryCounts.navigation || 0})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 레이아웃 목록 */}
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
{filteredLayouts.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center text-center text-sm text-gray-500">
|
||||||
|
{searchTerm ? "검색 결과가 없습니다." : "레이아웃이 없습니다."}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filteredLayouts.map((layout) => {
|
||||||
|
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={layout.id}
|
||||||
|
className="cursor-move transition-shadow hover:shadow-md"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, layout)}
|
||||||
|
onClick={() => handleLayoutSelect(layout)}
|
||||||
|
>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CategoryIcon className="h-4 w-4 text-gray-600" />
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{CATEGORY_NAMES[layout.category as keyof typeof CATEGORY_NAMES]}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-sm">{layout.name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
{layout.description && (
|
||||||
|
<p className="line-clamp-2 text-xs text-gray-600">{layout.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 text-xs text-gray-500">존 개수: {layout.defaultZones.length}개</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,283 @@
|
||||||
|
# 새로운 레이아웃 시스템 가이드
|
||||||
|
|
||||||
|
## 🎉 개요
|
||||||
|
|
||||||
|
화면관리 시스템의 레이아웃 구조가 크게 개선되었습니다. 새로운 시스템은 **자동 디스커버리**, **CLI 도구**, **Hot Reload**, **개발자 도구**를 제공하여 개발 경험을 혁신적으로 향상시킵니다.
|
||||||
|
|
||||||
|
## 📊 Before vs After
|
||||||
|
|
||||||
|
### Before (기존 구조)
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ 수동 등록 (5개 파일 수정)
|
||||||
|
❌ 50줄+ 보일러플레이트 코드
|
||||||
|
❌ Git 충돌 위험
|
||||||
|
❌ 30분-1시간 소요
|
||||||
|
❌ 타입 안전성 부족
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (새 구조)
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 1개 명령어로 완전 자동화
|
||||||
|
✅ 자동 생성된 템플릿
|
||||||
|
✅ 독립적 개발 (충돌 없음)
|
||||||
|
✅ 10-15분 소요
|
||||||
|
✅ 완전한 타입 안전성
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 새 레이아웃 생성
|
||||||
|
|
||||||
|
### 1. CLI 도구 사용
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run create-layout <name> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 실제 예시
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기본 아코디언 레이아웃
|
||||||
|
npm run create-layout accordion --category=navigation --zones=3
|
||||||
|
|
||||||
|
# 대시보드 레이아웃
|
||||||
|
npm run create-layout dashboard --category=business --zones=4
|
||||||
|
|
||||||
|
# 사이드바 레이아웃
|
||||||
|
npm run create-layout sidebar --category=navigation --zones=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 생성되는 파일들
|
||||||
|
|
||||||
|
```
|
||||||
|
layouts/myLayout/
|
||||||
|
├── index.ts # 레이아웃 정의 및 메타데이터
|
||||||
|
├── MyLayoutLayout.tsx # React 컴포넌트 (비즈니스 로직)
|
||||||
|
├── MyLayoutRenderer.tsx # 렌더러 (자동 등록)
|
||||||
|
├── config.ts # 기본 설정
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
└── README.md # 문서
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 개발 과정
|
||||||
|
|
||||||
|
### 1단계: 스캐폴딩
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run create-layout sidebar --category=navigation --zones=2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2단계: 비즈니스 로직 구현
|
||||||
|
|
||||||
|
`SidebarLayout.tsx`에서 렌더링 로직 구현:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const SidebarLayout: React.FC<SidebarLayoutProps> = ({
|
||||||
|
layout, isDesignMode, renderer, ...props
|
||||||
|
}) => {
|
||||||
|
const sidebarConfig = layout.layoutConfig.sidebar;
|
||||||
|
|
||||||
|
// 사이드바 전용 로직 구현
|
||||||
|
const sidebarStyle = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: sidebarConfig.position === "left" ? "row" : "row-reverse",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%"
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar-layout" style={sidebarStyle}>
|
||||||
|
{/* 사이드바와 메인 콘텐츠 영역 렌더링 */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3단계: 자동 등록 및 테스트
|
||||||
|
|
||||||
|
- 파일 저장 시 **자동으로 화면편집기에서 사용 가능**
|
||||||
|
- Hot Reload로 실시간 업데이트
|
||||||
|
- 브라우저 DevTools에서 확인 가능
|
||||||
|
|
||||||
|
## 🛠️ 개발자 도구
|
||||||
|
|
||||||
|
### 브라우저 콘솔에서 사용 가능한 명령어들:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 모든 레이아웃 목록 보기
|
||||||
|
__LAYOUT_REGISTRY__.list();
|
||||||
|
|
||||||
|
// 특정 레이아웃 상세 정보
|
||||||
|
__LAYOUT_REGISTRY__.get("grid");
|
||||||
|
|
||||||
|
// 레지스트리 통계
|
||||||
|
__LAYOUT_REGISTRY__.stats();
|
||||||
|
|
||||||
|
// 레이아웃 검색
|
||||||
|
__LAYOUT_REGISTRY__.search("flex");
|
||||||
|
|
||||||
|
// 카테고리별 레이아웃
|
||||||
|
__LAYOUT_REGISTRY__.categories();
|
||||||
|
|
||||||
|
// 도움말
|
||||||
|
__LAYOUT_REGISTRY__.help();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 새 구조 특징
|
||||||
|
|
||||||
|
### 1. 자동 등록
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 클래스 로드 시 자동 등록
|
||||||
|
export class MyLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
static readonly layoutDefinition = MyLayoutDefinition;
|
||||||
|
|
||||||
|
static {
|
||||||
|
this.registerSelf(); // 자동 실행
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 타입 안전성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 완전한 타입 정의
|
||||||
|
export const MyLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "myLayout",
|
||||||
|
name: "내 레이아웃",
|
||||||
|
component: MyLayout,
|
||||||
|
defaultConfig: {
|
||||||
|
myLayout: {
|
||||||
|
setting1: "value1",
|
||||||
|
setting2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 메타데이터 관리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "Developer Name",
|
||||||
|
documentation: "레이아웃 설명",
|
||||||
|
tags: ["tag1", "tag2"],
|
||||||
|
createdAt: "2025-01-10T..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔥 Hot Reload 지원
|
||||||
|
|
||||||
|
### 개발 모드에서 자동 업데이트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 개발 모드에서 Hot Reload 지원
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
(module as any).hot.dispose(() => {
|
||||||
|
MyLayoutRenderer.unregisterSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 현재 상태
|
||||||
|
|
||||||
|
### ✅ 완료된 기능들
|
||||||
|
|
||||||
|
- [x] 자동 디스커버리 시스템
|
||||||
|
- [x] CLI 스캐폴딩 도구
|
||||||
|
- [x] 자동 등록 베이스 클래스
|
||||||
|
- [x] 브라우저 개발자 도구
|
||||||
|
- [x] Hot Reload 지원
|
||||||
|
- [x] 타입 안전성
|
||||||
|
- [x] 메타데이터 관리
|
||||||
|
|
||||||
|
### 🔄 마이그레이션 현황
|
||||||
|
|
||||||
|
- [x] **Grid 레이아웃** → 새 구조 완료
|
||||||
|
- [x] **Flexbox 레이아웃** → 새 구조 완료
|
||||||
|
- [x] **Accordion 레이아웃** → 새 구조 완료 (신규)
|
||||||
|
- [ ] Split 레이아웃 → 예정
|
||||||
|
- [ ] Tabs 레이아웃 → 예정
|
||||||
|
|
||||||
|
## 🎯 마이그레이션 가이드
|
||||||
|
|
||||||
|
### 기존 레이아웃을 새 구조로 변환하기:
|
||||||
|
|
||||||
|
1. **CLI로 스캐폴딩 생성**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run create-layout myExistingLayout --category=basic
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **기존 로직 복사**
|
||||||
|
- 기존 `MyLayoutRenderer.tsx`의 로직을 새 `MyLayout.tsx`로 복사
|
||||||
|
- 렌더링 로직 적응
|
||||||
|
|
||||||
|
3. **설정 정의**
|
||||||
|
- `index.ts`에서 `defaultConfig` 정의
|
||||||
|
- 기존 설정과 호환성 유지
|
||||||
|
|
||||||
|
4. **자동 등록 활성화**
|
||||||
|
- `layouts/index.ts`에 새 import 추가
|
||||||
|
- 기존 수동 등록 제거
|
||||||
|
|
||||||
|
## 🚦 마이그레이션 체크리스트
|
||||||
|
|
||||||
|
### 레이아웃 개발자용:
|
||||||
|
|
||||||
|
- [ ] CLI 도구로 새 레이아웃 생성
|
||||||
|
- [ ] 비즈니스 로직 구현
|
||||||
|
- [ ] 브라우저에서 `__LAYOUT_REGISTRY__.list()` 확인
|
||||||
|
- [ ] 화면편집기에서 레이아웃 선택 가능 확인
|
||||||
|
- [ ] Hot Reload 동작 확인
|
||||||
|
|
||||||
|
### 시스템 관리자용:
|
||||||
|
|
||||||
|
- [ ] 모든 기존 레이아웃 새 구조로 마이그레이션
|
||||||
|
- [ ] 기존 수동 등록 코드 제거
|
||||||
|
- [ ] 개발자 가이드 업데이트
|
||||||
|
- [ ] 팀 교육 실시
|
||||||
|
|
||||||
|
## 💡 개발 팁
|
||||||
|
|
||||||
|
### 1. 브라우저 DevTools 활용
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 개발 중 레이아웃 확인
|
||||||
|
__LAYOUT_REGISTRY__.get("myLayout");
|
||||||
|
|
||||||
|
// 카테고리별 현황 파악
|
||||||
|
__LAYOUT_REGISTRY__.categories();
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Hot Reload 최대한 활용
|
||||||
|
|
||||||
|
- 파일 저장 시 즉시 반영
|
||||||
|
- 브라우저 새로고침 불필요
|
||||||
|
- 실시간 디버깅 가능
|
||||||
|
|
||||||
|
### 3. 타입 안전성 확보
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 설정 타입 정의로 IDE 지원
|
||||||
|
interface MyLayoutConfig {
|
||||||
|
orientation: "vertical" | "horizontal";
|
||||||
|
spacing: number;
|
||||||
|
collapsible: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎉 결론
|
||||||
|
|
||||||
|
새로운 레이아웃 시스템으로 **개발 속도 3-4배 향상**, **충돌 위험 제거**, **타입 안전성 확보**가 가능해졌습니다.
|
||||||
|
|
||||||
|
**5분이면 새 레이아웃 생성부터 화면편집기 등록까지 완료!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
📞 **문의사항이나 문제가 있으면 언제든 연락주세요!**
|
||||||
|
|
@ -0,0 +1,701 @@
|
||||||
|
# 화면관리 시스템 레이아웃 기능 설계서
|
||||||
|
|
||||||
|
## 1. 개요
|
||||||
|
|
||||||
|
### 1.1 목적
|
||||||
|
|
||||||
|
화면관리 시스템에 동적 레이아웃 기능을 추가하여 다양한 화면 구조를 효율적으로 설계할 수 있도록 한다. 레이아웃은 컴포넌트들을 구조화된 영역으로 배치할 수 있는 컨테이너 역할을 하며, 동적으로 생성하고 관리할 수 있도록 설계한다.
|
||||||
|
|
||||||
|
### 1.2 범위
|
||||||
|
|
||||||
|
- 레이아웃 관리 메뉴 및 기능 개발
|
||||||
|
- 다양한 레이아웃 타입 및 설정 기능
|
||||||
|
- 레지스트리 기반 동적 레이아웃 컴포넌트 시스템
|
||||||
|
- 기존 화면관리 시스템과의 통합
|
||||||
|
|
||||||
|
## 2. 현재 시스템 분석
|
||||||
|
|
||||||
|
### 2.1 기존 데이터베이스 구조
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 현재 화면관리 관련 테이블들
|
||||||
|
screen_definitions -- 화면 정의
|
||||||
|
screen_layouts -- 화면 레이아웃 (컴포넌트 배치)
|
||||||
|
screen_widgets -- 위젯 설정
|
||||||
|
screen_templates -- 화면 템플릿
|
||||||
|
template_standards -- 템플릿 표준
|
||||||
|
component_standards -- 컴포넌트 표준
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 기존 컴포넌트 타입
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable" | "file" | "area";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 현재 레지스트리 시스템
|
||||||
|
|
||||||
|
- `ComponentRegistry`: 컴포넌트 동적 등록 및 관리
|
||||||
|
- `WebTypeRegistry`: 웹타입 동적 등록 및 관리
|
||||||
|
- `DynamicComponentRenderer`: 동적 컴포넌트 렌더링
|
||||||
|
|
||||||
|
## 3. 레이아웃 기능 설계
|
||||||
|
|
||||||
|
### 3.1 레이아웃 타입 정의
|
||||||
|
|
||||||
|
#### 3.1.1 기본 레이아웃 타입
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type LayoutType =
|
||||||
|
| "grid" // 그리드 레이아웃 (n x m 격자)
|
||||||
|
| "flexbox" // 플렉스박스 레이아웃
|
||||||
|
| "split" // 분할 레이아웃 (수직/수평)
|
||||||
|
| "card" // 카드 레이아웃
|
||||||
|
| "tabs" // 탭 레이아웃
|
||||||
|
| "accordion" // 아코디언 레이아웃
|
||||||
|
| "sidebar" // 사이드바 레이아웃
|
||||||
|
| "header-footer" // 헤더-푸터 레이아웃
|
||||||
|
| "three-column" // 3단 레이아웃
|
||||||
|
| "dashboard" // 대시보드 레이아웃
|
||||||
|
| "form" // 폼 레이아웃
|
||||||
|
| "table" // 테이블 레이아웃
|
||||||
|
| "custom"; // 커스텀 레이아웃
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.1.2 레이아웃 컴포넌트 인터페이스
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface LayoutComponent extends BaseComponent {
|
||||||
|
type: "layout";
|
||||||
|
layoutType: LayoutType;
|
||||||
|
layoutConfig: LayoutConfig;
|
||||||
|
children: ComponentData[];
|
||||||
|
zones: LayoutZone[]; // 레이아웃 영역 정의
|
||||||
|
allowedComponentTypes?: ComponentType[]; // 허용된 자식 컴포넌트 타입
|
||||||
|
dropZoneConfig?: DropZoneConfig; // 드롭존 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: {
|
||||||
|
row?: number;
|
||||||
|
column?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
};
|
||||||
|
size: {
|
||||||
|
width: number | string;
|
||||||
|
height: number | string;
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
};
|
||||||
|
style?: ComponentStyle;
|
||||||
|
allowedComponents?: ComponentType[];
|
||||||
|
isResizable?: boolean;
|
||||||
|
isRequired?: boolean; // 필수 영역 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LayoutConfig {
|
||||||
|
// 그리드 레이아웃 설정
|
||||||
|
grid?: {
|
||||||
|
rows: number;
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
rowGap?: number;
|
||||||
|
columnGap?: number;
|
||||||
|
autoRows?: string;
|
||||||
|
autoColumns?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 플렉스박스 설정
|
||||||
|
flexbox?: {
|
||||||
|
direction: "row" | "column" | "row-reverse" | "column-reverse";
|
||||||
|
justify: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
|
||||||
|
align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
|
||||||
|
wrap: "nowrap" | "wrap" | "wrap-reverse";
|
||||||
|
gap: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 분할 레이아웃 설정
|
||||||
|
split?: {
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
ratio: number[]; // 각 영역의 비율 [30, 70]
|
||||||
|
minSize: number[]; // 각 영역의 최소 크기
|
||||||
|
resizable: boolean; // 크기 조절 가능 여부
|
||||||
|
splitterSize: number; // 분할선 두께
|
||||||
|
};
|
||||||
|
|
||||||
|
// 탭 레이아웃 설정
|
||||||
|
tabs?: {
|
||||||
|
position: "top" | "bottom" | "left" | "right";
|
||||||
|
variant: "default" | "pills" | "underline";
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
defaultTab: string; // 기본 선택 탭
|
||||||
|
closable: boolean; // 탭 닫기 가능 여부
|
||||||
|
};
|
||||||
|
|
||||||
|
// 아코디언 설정
|
||||||
|
accordion?: {
|
||||||
|
multiple: boolean; // 다중 확장 허용
|
||||||
|
defaultExpanded: string[]; // 기본 확장 항목
|
||||||
|
collapsible: boolean; // 모두 닫기 허용
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사이드바 설정
|
||||||
|
sidebar?: {
|
||||||
|
position: "left" | "right";
|
||||||
|
width: number | string;
|
||||||
|
collapsible: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
overlay: boolean; // 오버레이 모드
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더-푸터 설정
|
||||||
|
headerFooter?: {
|
||||||
|
headerHeight: number | string;
|
||||||
|
footerHeight: number | string;
|
||||||
|
stickyHeader: boolean;
|
||||||
|
stickyFooter: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 설정
|
||||||
|
dashboard?: {
|
||||||
|
columns: number;
|
||||||
|
rowHeight: number;
|
||||||
|
margin: [number, number];
|
||||||
|
padding: [number, number];
|
||||||
|
isDraggable: boolean;
|
||||||
|
isResizable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 커스텀 설정
|
||||||
|
custom?: {
|
||||||
|
cssProperties: Record<string, string>;
|
||||||
|
className: string;
|
||||||
|
template: string; // HTML 템플릿
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropZoneConfig {
|
||||||
|
showDropZones: boolean;
|
||||||
|
dropZoneStyle?: ComponentStyle;
|
||||||
|
highlightOnDragOver: boolean;
|
||||||
|
allowedTypes?: ComponentType[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 데이터베이스 스키마 확장
|
||||||
|
|
||||||
|
#### 3.2.1 레이아웃 표준 관리 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 레이아웃 표준 관리 테이블
|
||||||
|
CREATE TABLE layout_standards (
|
||||||
|
layout_code VARCHAR(50) PRIMARY KEY,
|
||||||
|
layout_name VARCHAR(100) NOT NULL,
|
||||||
|
layout_name_eng VARCHAR(100),
|
||||||
|
description TEXT,
|
||||||
|
layout_type VARCHAR(50) NOT NULL,
|
||||||
|
category VARCHAR(50) NOT NULL,
|
||||||
|
icon_name VARCHAR(50),
|
||||||
|
default_size JSON,
|
||||||
|
layout_config JSON NOT NULL,
|
||||||
|
zones_config JSON NOT NULL,
|
||||||
|
preview_image VARCHAR(255),
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
is_active CHAR(1) DEFAULT 'Y',
|
||||||
|
is_public CHAR(1) DEFAULT 'Y',
|
||||||
|
company_code VARCHAR(50) NOT NULL,
|
||||||
|
created_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
updated_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by VARCHAR(50)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 인덱스 생성
|
||||||
|
CREATE INDEX idx_layout_standards_type ON layout_standards(layout_type);
|
||||||
|
CREATE INDEX idx_layout_standards_category ON layout_standards(category);
|
||||||
|
CREATE INDEX idx_layout_standards_company ON layout_standards(company_code);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.2 기존 테이블 확장
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- screen_layouts 테이블에 레이아웃 관련 컬럼 추가
|
||||||
|
ALTER TABLE screen_layouts ADD COLUMN layout_type VARCHAR(50);
|
||||||
|
ALTER TABLE screen_layouts ADD COLUMN layout_config JSON;
|
||||||
|
ALTER TABLE screen_layouts ADD COLUMN zones_config JSON;
|
||||||
|
ALTER TABLE screen_layouts ADD COLUMN zone_id VARCHAR(100);
|
||||||
|
|
||||||
|
-- component_standards 테이블에서 레이아웃 타입 지원
|
||||||
|
-- category에 'layout' 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 레이아웃 카테고리 및 사전 정의 레이아웃
|
||||||
|
|
||||||
|
#### 3.3.1 레이아웃 카테고리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const LAYOUT_CATEGORIES = {
|
||||||
|
BASIC: "basic", // 기본 레이아웃
|
||||||
|
FORM: "form", // 폼 레이아웃
|
||||||
|
TABLE: "table", // 테이블 레이아웃
|
||||||
|
DASHBOARD: "dashboard", // 대시보드 레이아웃
|
||||||
|
NAVIGATION: "navigation", // 네비게이션 레이아웃
|
||||||
|
CONTENT: "content", // 컨텐츠 레이아웃
|
||||||
|
BUSINESS: "business", // 업무용 레이아웃
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 사전 정의 레이아웃 템플릿
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const PREDEFINED_LAYOUTS = [
|
||||||
|
// 기본 레이아웃
|
||||||
|
{
|
||||||
|
code: "GRID_2X2",
|
||||||
|
name: "2x2 그리드",
|
||||||
|
type: "grid",
|
||||||
|
category: "basic",
|
||||||
|
config: {
|
||||||
|
grid: { rows: 2, columns: 2, gap: 16 },
|
||||||
|
},
|
||||||
|
zones: [
|
||||||
|
{ id: "zone1", name: "상단 좌측", position: { row: 0, column: 0 } },
|
||||||
|
{ id: "zone2", name: "상단 우측", position: { row: 0, column: 1 } },
|
||||||
|
{ id: "zone3", name: "하단 좌측", position: { row: 1, column: 0 } },
|
||||||
|
{ id: "zone4", name: "하단 우측", position: { row: 1, column: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 폼 레이아웃
|
||||||
|
{
|
||||||
|
code: "FORM_TWO_COLUMN",
|
||||||
|
name: "2단 폼 레이아웃",
|
||||||
|
type: "grid",
|
||||||
|
category: "form",
|
||||||
|
config: {
|
||||||
|
grid: { rows: 1, columns: 2, gap: 24 },
|
||||||
|
},
|
||||||
|
zones: [
|
||||||
|
{ id: "left", name: "좌측 입력 영역", position: { row: 0, column: 0 } },
|
||||||
|
{ id: "right", name: "우측 입력 영역", position: { row: 0, column: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 대시보드 레이아웃
|
||||||
|
{
|
||||||
|
code: "DASHBOARD_MAIN",
|
||||||
|
name: "메인 대시보드",
|
||||||
|
type: "grid",
|
||||||
|
category: "dashboard",
|
||||||
|
config: {
|
||||||
|
grid: { rows: 3, columns: 4, gap: 16 },
|
||||||
|
},
|
||||||
|
zones: [
|
||||||
|
{ id: "header", name: "헤더", position: { row: 0, column: 0 }, size: { width: "100%", height: "80px" } },
|
||||||
|
{ id: "sidebar", name: "사이드바", position: { row: 1, column: 0 }, size: { width: "250px", height: "100%" } },
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
name: "메인 컨텐츠",
|
||||||
|
position: { row: 1, column: 1 },
|
||||||
|
size: { width: "calc(100% - 250px)", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 테이블 레이아웃
|
||||||
|
{
|
||||||
|
code: "TABLE_WITH_FILTERS",
|
||||||
|
name: "필터가 있는 테이블",
|
||||||
|
type: "flexbox",
|
||||||
|
category: "table",
|
||||||
|
config: {
|
||||||
|
flexbox: { direction: "column", gap: 16 },
|
||||||
|
},
|
||||||
|
zones: [
|
||||||
|
{ id: "filters", name: "검색 필터", size: { width: "100%", height: "auto" } },
|
||||||
|
{ id: "table", name: "데이터 테이블", size: { width: "100%", height: "1fr" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 분할 레이아웃
|
||||||
|
{
|
||||||
|
code: "SPLIT_HORIZONTAL",
|
||||||
|
name: "수평 분할",
|
||||||
|
type: "split",
|
||||||
|
category: "basic",
|
||||||
|
config: {
|
||||||
|
split: { direction: "horizontal", ratio: [50, 50], resizable: true },
|
||||||
|
},
|
||||||
|
zones: [
|
||||||
|
{ id: "left", name: "좌측 영역", isResizable: true },
|
||||||
|
{ id: "right", name: "우측 영역", isResizable: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 탭 레이아웃
|
||||||
|
{
|
||||||
|
code: "TABS_HORIZONTAL",
|
||||||
|
name: "수평 탭",
|
||||||
|
type: "tabs",
|
||||||
|
category: "navigation",
|
||||||
|
config: {
|
||||||
|
tabs: { position: "top", variant: "default", defaultTab: "tab1" },
|
||||||
|
},
|
||||||
|
zones: [
|
||||||
|
{ id: "tab1", name: "첫 번째 탭" },
|
||||||
|
{ id: "tab2", name: "두 번째 탭" },
|
||||||
|
{ id: "tab3", name: "세 번째 탭" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 구현 계획
|
||||||
|
|
||||||
|
### 4.1 Phase 1: 기본 인프라 구축
|
||||||
|
|
||||||
|
#### 4.1.1 데이터베이스 스키마 생성
|
||||||
|
|
||||||
|
- `layout_standards` 테이블 생성
|
||||||
|
- 기존 테이블 확장
|
||||||
|
- 기본 레이아웃 데이터 삽입
|
||||||
|
|
||||||
|
#### 4.1.2 타입 정의 및 인터페이스
|
||||||
|
|
||||||
|
- `frontend/types/layout.ts` 생성
|
||||||
|
- 기존 `screen.ts` 확장
|
||||||
|
|
||||||
|
#### 4.1.3 레이아웃 레지스트리 시스템
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/registry/LayoutRegistry.ts
|
||||||
|
export class LayoutRegistry {
|
||||||
|
private static layouts = new Map<string, LayoutDefinition>();
|
||||||
|
|
||||||
|
static registerLayout(definition: LayoutDefinition): void;
|
||||||
|
static getLayout(layoutType: string): LayoutDefinition | undefined;
|
||||||
|
static getLayoutsByCategory(category: string): LayoutDefinition[];
|
||||||
|
static getAllLayouts(): LayoutDefinition[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Phase 2: 레이아웃 관리 기능
|
||||||
|
|
||||||
|
#### 4.2.1 레이아웃 관리 메뉴
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/app/(main)/admin/layouts/page.tsx
|
||||||
|
- 레이아웃 목록 조회
|
||||||
|
- 레이아웃 생성/수정/삭제
|
||||||
|
- 레이아웃 미리보기
|
||||||
|
- 레이아웃 내보내기/가져오기
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.2 레이아웃 편집기
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/layout/LayoutDesigner.tsx
|
||||||
|
- 드래그앤드롭 레이아웃 편집
|
||||||
|
- 실시간 미리보기
|
||||||
|
- 존 설정 편집
|
||||||
|
- 레이아웃 설정 편집
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.3 백엔드 API
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/routes/layoutRoutes.ts
|
||||||
|
GET /api/layouts // 레이아웃 목록 조회
|
||||||
|
GET /api/layouts/:id // 레이아웃 상세 조회
|
||||||
|
POST /api/layouts // 레이아웃 생성
|
||||||
|
PUT /api/layouts/:id // 레이아웃 수정
|
||||||
|
DELETE /api/layouts/:id // 레이아웃 삭제
|
||||||
|
POST /api/layouts/:id/duplicate // 레이아웃 복제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Phase 3: 레이아웃 컴포넌트 구현
|
||||||
|
|
||||||
|
#### 4.3.1 기본 레이아웃 컴포넌트들
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/registry/layouts/
|
||||||
|
├── GridLayoutRenderer.tsx // 그리드 레이아웃
|
||||||
|
├── FlexboxLayoutRenderer.tsx // 플렉스박스 레이아웃
|
||||||
|
├── SplitLayoutRenderer.tsx // 분할 레이아웃
|
||||||
|
├── TabsLayoutRenderer.tsx // 탭 레이아웃
|
||||||
|
├── AccordionLayoutRenderer.tsx // 아코디언 레이아웃
|
||||||
|
├── SidebarLayoutRenderer.tsx // 사이드바 레이아웃
|
||||||
|
├── HeaderFooterLayoutRenderer.tsx // 헤더-푸터 레이아웃
|
||||||
|
└── CustomLayoutRenderer.tsx // 커스텀 레이아웃
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.3.2 레이아웃 설정 패널
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/layout/config-panels/
|
||||||
|
├── GridConfigPanel.tsx
|
||||||
|
├── FlexboxConfigPanel.tsx
|
||||||
|
├── SplitConfigPanel.tsx
|
||||||
|
├── TabsConfigPanel.tsx
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Phase 4: 화면관리 시스템 통합
|
||||||
|
|
||||||
|
#### 4.4.1 화면 디자이너 확장
|
||||||
|
|
||||||
|
- 레이아웃 팔레트 추가
|
||||||
|
- 레이아웃 드래그앤드롭 지원
|
||||||
|
- 레이아웃 존에 컴포넌트 배치
|
||||||
|
|
||||||
|
#### 4.4.2 실시간 미리보기 지원
|
||||||
|
|
||||||
|
- 레이아웃 렌더링 지원
|
||||||
|
- 존별 컴포넌트 렌더링
|
||||||
|
- 레이아웃 상호작용 지원
|
||||||
|
|
||||||
|
## 5. 기술적 구현 세부사항
|
||||||
|
|
||||||
|
### 5.1 레이아웃 렌더러 기본 구조
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/registry/layouts/BaseLayoutRenderer.tsx
|
||||||
|
interface LayoutRendererProps {
|
||||||
|
layout: LayoutComponent;
|
||||||
|
children: ComponentData[];
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
onZoneClick?: (zoneId: string) => void;
|
||||||
|
onComponentDrop?: (zoneId: string, component: ComponentData) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererProps> {
|
||||||
|
abstract render(): React.ReactElement;
|
||||||
|
|
||||||
|
protected renderZone(zone: LayoutZone, children: ComponentData[]): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`layout-zone ${this.props.isDesignMode ? 'design-mode' : ''}`}
|
||||||
|
data-zone-id={zone.id}
|
||||||
|
onClick={() => this.props.onZoneClick?.(zone.id)}
|
||||||
|
onDrop={this.handleDrop}
|
||||||
|
onDragOver={this.handleDragOver}
|
||||||
|
>
|
||||||
|
{children.map(child => (
|
||||||
|
<DynamicComponentRenderer key={child.id} component={child} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleDrop = (e: React.DragEvent) => {
|
||||||
|
// 드롭 처리 로직
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleDragOver = (e: React.DragEvent) => {
|
||||||
|
// 드래그오버 처리 로직
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 동적 레이아웃 등록 시스템
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/registry/layouts/index.ts
|
||||||
|
import { LayoutRegistry } from "../LayoutRegistry";
|
||||||
|
import GridLayoutRenderer from "./GridLayoutRenderer";
|
||||||
|
import FlexboxLayoutRenderer from "./FlexboxLayoutRenderer";
|
||||||
|
// ... 다른 레이아웃 import
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트들을 레지스트리에 등록
|
||||||
|
LayoutRegistry.registerLayout({
|
||||||
|
id: "grid",
|
||||||
|
name: "그리드 레이아웃",
|
||||||
|
component: GridLayoutRenderer,
|
||||||
|
category: "basic",
|
||||||
|
icon: "grid",
|
||||||
|
defaultConfig: {
|
||||||
|
grid: { rows: 2, columns: 2, gap: 16 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
LayoutRegistry.registerLayout({
|
||||||
|
id: "flexbox",
|
||||||
|
name: "플렉스박스 레이아웃",
|
||||||
|
component: FlexboxLayoutRenderer,
|
||||||
|
category: "basic",
|
||||||
|
icon: "flex",
|
||||||
|
defaultConfig: {
|
||||||
|
flexbox: { direction: "row", justify: "flex-start", align: "stretch" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 레이아웃 팔레트 컴포넌트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/screen/panels/LayoutsPanel.tsx
|
||||||
|
export default function LayoutsPanel({ onDragStart }: LayoutsPanelProps) {
|
||||||
|
const [layouts] = useState(() => LayoutRegistry.getAllLayouts());
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
|
||||||
|
const filteredLayouts = useMemo(() => {
|
||||||
|
if (selectedCategory === 'all') return layouts;
|
||||||
|
return layouts.filter(layout => layout.category === selectedCategory);
|
||||||
|
}, [layouts, selectedCategory]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="layouts-panel">
|
||||||
|
<div className="category-tabs">
|
||||||
|
{LAYOUT_CATEGORIES.map(category => (
|
||||||
|
<Button
|
||||||
|
key={category.id}
|
||||||
|
variant={selectedCategory === category.id ? 'default' : 'ghost'}
|
||||||
|
onClick={() => setSelectedCategory(category.id)}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="layout-grid">
|
||||||
|
{filteredLayouts.map(layout => (
|
||||||
|
<div
|
||||||
|
key={layout.id}
|
||||||
|
className="layout-item"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => onDragStart(e, layout)}
|
||||||
|
>
|
||||||
|
<div className="layout-preview">
|
||||||
|
<layout.icon />
|
||||||
|
</div>
|
||||||
|
<div className="layout-info">
|
||||||
|
<h4>{layout.name}</h4>
|
||||||
|
<p>{layout.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 사용자 인터페이스 설계
|
||||||
|
|
||||||
|
### 6.1 레이아웃 관리 화면
|
||||||
|
|
||||||
|
- **레이아웃 목록**: 그리드 형태로 레이아웃 목록 표시
|
||||||
|
- **카테고리 필터**: 카테고리별 레이아웃 필터링
|
||||||
|
- **검색 기능**: 레이아웃 이름/설명으로 검색
|
||||||
|
- **미리보기**: 레이아웃 구조 미리보기
|
||||||
|
- **편집 버튼**: 레이아웃 편집 모드 진입
|
||||||
|
|
||||||
|
### 6.2 레이아웃 편집기
|
||||||
|
|
||||||
|
- **캔버스 영역**: 레이아웃 시각적 편집
|
||||||
|
- **존 편집**: 각 존의 크기/위치 조정
|
||||||
|
- **속성 패널**: 레이아웃 설정 편집
|
||||||
|
- **미리보기 모드**: 실제 렌더링 미리보기
|
||||||
|
|
||||||
|
### 6.3 화면 디자이너 확장
|
||||||
|
|
||||||
|
- **레이아웃 팔레트**: 사용 가능한 레이아웃 목록
|
||||||
|
- **드래그앤드롭**: 레이아웃을 캔버스에 배치
|
||||||
|
- **존 하이라이트**: 컴포넌트 드롭 가능한 존 표시
|
||||||
|
|
||||||
|
## 7. 보안 및 권한 관리
|
||||||
|
|
||||||
|
### 7.1 레이아웃 접근 권한
|
||||||
|
|
||||||
|
- **생성 권한**: 레이아웃 생성 권한
|
||||||
|
- **수정 권한**: 레이아웃 수정 권한
|
||||||
|
- **삭제 권한**: 레이아웃 삭제 권한
|
||||||
|
- **공개 설정**: 다른 사용자와 레이아웃 공유
|
||||||
|
|
||||||
|
### 7.2 회사별 레이아웃 관리
|
||||||
|
|
||||||
|
- **회사 코드**: 레이아웃의 회사 소속 관리
|
||||||
|
- **공개 레이아웃**: 모든 회사에서 사용 가능한 레이아웃
|
||||||
|
- **비공개 레이아웃**: 특정 회사에서만 사용 가능한 레이아웃
|
||||||
|
|
||||||
|
## 8. 성능 최적화
|
||||||
|
|
||||||
|
### 8.1 레이아웃 렌더링 최적화
|
||||||
|
|
||||||
|
- **지연 로딩**: 필요한 레이아웃 컴포넌트만 로딩
|
||||||
|
- **메모이제이션**: 레이아웃 설정 변경 시에만 리렌더링
|
||||||
|
- **가상화**: 대량의 레이아웃 목록 가상화
|
||||||
|
|
||||||
|
### 8.2 캐싱 전략
|
||||||
|
|
||||||
|
- **레이아웃 정의 캐싱**: 자주 사용되는 레이아웃 정의 캐싱
|
||||||
|
- **렌더링 결과 캐싱**: 동일한 설정의 레이아웃 렌더링 결과 캐싱
|
||||||
|
|
||||||
|
## 9. 테스트 계획
|
||||||
|
|
||||||
|
### 9.1 단위 테스트
|
||||||
|
|
||||||
|
- 레이아웃 컴포넌트 렌더링 테스트
|
||||||
|
- 레이아웃 설정 변경 테스트
|
||||||
|
- 드래그앤드롭 기능 테스트
|
||||||
|
|
||||||
|
### 9.2 통합 테스트
|
||||||
|
|
||||||
|
- 화면관리 시스템과의 통합 테스트
|
||||||
|
- 데이터베이스 연동 테스트
|
||||||
|
- API 엔드포인트 테스트
|
||||||
|
|
||||||
|
### 9.3 사용자 테스트
|
||||||
|
|
||||||
|
- 레이아웃 생성/편집 시나리오 테스트
|
||||||
|
- 다양한 브라우저 호환성 테스트
|
||||||
|
|
||||||
|
## 10. 마이그레이션 계획
|
||||||
|
|
||||||
|
### 10.1 기존 화면 마이그레이션
|
||||||
|
|
||||||
|
- 기존 컨테이너 컴포넌트를 레이아웃으로 변환
|
||||||
|
- 기존 화면 구조를 레이아웃 기반으로 재구성
|
||||||
|
|
||||||
|
### 10.2 단계별 배포
|
||||||
|
|
||||||
|
1. **Phase 1**: 레이아웃 관리 기능 배포
|
||||||
|
2. **Phase 2**: 기본 레이아웃 컴포넌트 배포
|
||||||
|
3. **Phase 3**: 화면관리 시스템 통합
|
||||||
|
4. **Phase 4**: 기존 화면 마이그레이션
|
||||||
|
|
||||||
|
## 11. 향후 확장 계획
|
||||||
|
|
||||||
|
### 11.1 고급 레이아웃 기능
|
||||||
|
|
||||||
|
- **반응형 레이아웃**: 화면 크기에 따른 레이아웃 변경
|
||||||
|
- **애니메이션**: 레이아웃 전환 애니메이션
|
||||||
|
- **테마 지원**: 레이아웃별 테마 설정
|
||||||
|
|
||||||
|
### 11.2 AI 기반 레이아웃 추천
|
||||||
|
|
||||||
|
- 데이터 타입에 따른 레이아웃 자동 추천
|
||||||
|
- 사용 패턴 분석을 통한 최적 레이아웃 제안
|
||||||
|
|
||||||
|
### 11.3 협업 기능
|
||||||
|
|
||||||
|
- **실시간 편집**: 여러 사용자가 동시에 레이아웃 편집
|
||||||
|
- **버전 관리**: 레이아웃 변경 이력 관리
|
||||||
|
- **댓글 시스템**: 레이아웃에 대한 피드백 시스템
|
||||||
|
|
||||||
|
## 12. 결론
|
||||||
|
|
||||||
|
이 설계서에 따라 레이아웃 기능을 구현하면, 화면관리 시스템의 유연성과 확장성이 크게 향상될 것입니다. 동적 레지스트리 시스템을 통해 새로운 레이아웃 타입을 쉽게 추가할 수 있으며, 사용자는 다양한 화면 구조를 효율적으로 설계할 수 있게 됩니다.
|
||||||
|
|
||||||
|
주요 장점:
|
||||||
|
|
||||||
|
- **확장성**: 새로운 레이아웃 타입 쉽게 추가
|
||||||
|
- **재사용성**: 레이아웃 템플릿 재사용으로 개발 효율성 향상
|
||||||
|
- **유연성**: 다양한 화면 요구사항에 대응 가능
|
||||||
|
- **일관성**: 표준화된 레이아웃을 통한 UI 일관성 확보
|
||||||
|
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
# 레이아웃 추가 가이드
|
||||||
|
|
||||||
|
화면관리 시스템에서 새로운 레이아웃을 추가하는 방법을 설명합니다.
|
||||||
|
|
||||||
|
## 📋 목차
|
||||||
|
|
||||||
|
1. [CLI를 이용한 자동 생성](#cli를-이용한-자동-생성)
|
||||||
|
2. [생성된 파일 구조](#생성된-파일-구조)
|
||||||
|
3. [레이아웃 커스터마이징](#레이아웃-커스터마이징)
|
||||||
|
4. [고급 설정](#고급-설정)
|
||||||
|
5. [문제 해결](#문제-해결)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 CLI를 이용한 자동 생성
|
||||||
|
|
||||||
|
### 기본 사용법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 기본 형태
|
||||||
|
node scripts/create-layout.js <레이아웃이름> [옵션]
|
||||||
|
|
||||||
|
# 예시
|
||||||
|
node scripts/create-layout.js card-grid --category=dashboard --zones=6 --description="카드 형태의 그리드 레이아웃"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📝 사용 가능한 옵션
|
||||||
|
|
||||||
|
| 옵션 | 필수 여부 | 기본값 | 설명 | 예시 |
|
||||||
|
| --------------- | --------- | ----------- | ----------------- | ----------------------------------- |
|
||||||
|
| `--category` | 선택 | `basic` | 레이아웃 카테고리 | `--category=dashboard` |
|
||||||
|
| `--zones` | 선택 | `2` | 영역 개수 | `--zones=4` |
|
||||||
|
| `--description` | 선택 | 자동 생성 | 레이아웃 설명 | `--description="사이드바 레이아웃"` |
|
||||||
|
| `--author` | 선택 | `Developer` | 작성자 이름 | `--author="김개발"` |
|
||||||
|
|
||||||
|
### 🏷️ 카테고리 종류
|
||||||
|
|
||||||
|
| 카테고리 | 설명 | 예시 레이아웃 |
|
||||||
|
| ------------ | ------------- | ------------------------ |
|
||||||
|
| `basic` | 기본 레이아웃 | 그리드, 플렉스박스, 분할 |
|
||||||
|
| `navigation` | 네비게이션 | 탭, 아코디언, 메뉴 |
|
||||||
|
| `dashboard` | 대시보드 | 카드, 위젯, 차트 |
|
||||||
|
| `content` | 콘텐츠 | 헤더-본문, 영웅 섹션 |
|
||||||
|
| `form` | 폼 | 입력 폼, 설정 패널 |
|
||||||
|
| `table` | 테이블 | 데이터 테이블, 목록 |
|
||||||
|
|
||||||
|
### 💡 이름 규칙
|
||||||
|
|
||||||
|
레이아웃 이름은 **하이픈(`-`)을 사용한 kebab-case**로 입력하면 자동으로 변환됩니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 입력: hero-section
|
||||||
|
📁 디렉토리: hero-section/
|
||||||
|
🔖 ID: hero-section
|
||||||
|
📄 클래스명: HeroSection
|
||||||
|
🔧 변수명: heroSection
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ✅ 올바른 이름 예시
|
||||||
|
|
||||||
|
- `card-grid`
|
||||||
|
- `side-navigation`
|
||||||
|
- `data-table`
|
||||||
|
- `hero-section`
|
||||||
|
- `my-awesome-layout`
|
||||||
|
|
||||||
|
#### ❌ 피해야 할 이름
|
||||||
|
|
||||||
|
- `CardGrid` (파스칼케이스)
|
||||||
|
- `card_grid` (스네이크케이스)
|
||||||
|
- `cardGrid` (카멜케이스)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 생성된 파일 구조
|
||||||
|
|
||||||
|
CLI로 레이아웃을 생성하면 다음과 같은 파일들이 자동으로 생성됩니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/registry/layouts/your-layout/
|
||||||
|
├── index.ts # 레이아웃 정의 및 등록
|
||||||
|
├── YourLayoutLayout.tsx # React 컴포넌트
|
||||||
|
├── YourLayoutRenderer.tsx # 렌더링 로직
|
||||||
|
├── config.ts # 기본 설정
|
||||||
|
├── types.ts # 타입 정의
|
||||||
|
└── README.md # 문서
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔧 각 파일의 역할
|
||||||
|
|
||||||
|
#### 1. `index.ts` - 레이아웃 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const YourLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "your-layout",
|
||||||
|
name: "yourLayout",
|
||||||
|
nameEng: "Your Layout",
|
||||||
|
description: "사용자 정의 레이아웃입니다",
|
||||||
|
category: "basic",
|
||||||
|
component: YourLayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
/* 기본 설정 */
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
/* 기본 영역들 */
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. `YourLayoutLayout.tsx` - React 컴포넌트
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const YourLayoutLayout: React.FC<YourLayoutProps> = ({ layout, isDesignMode, renderer, ...props }) => {
|
||||||
|
// 레이아웃 UI 구현
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `YourLayoutRenderer.tsx` - 렌더링 로직
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class YourLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
static layoutDefinition = YourLayoutDefinition;
|
||||||
|
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <YourLayoutLayout {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 레이아웃 커스터마이징
|
||||||
|
|
||||||
|
### 1. 기본 구조 수정
|
||||||
|
|
||||||
|
생성된 `YourLayoutLayout.tsx`에서 레이아웃 구조를 정의합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const YourLayoutLayout: React.FC<YourLayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode = false,
|
||||||
|
renderer,
|
||||||
|
}) => {
|
||||||
|
const yourLayoutConfig = layout.layoutConfig.yourLayout;
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// 레이아웃별 커스텀 스타일
|
||||||
|
const yourLayoutStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(3, 1fr)",
|
||||||
|
gridTemplateRows: "repeat(2, 200px)",
|
||||||
|
gap: "16px",
|
||||||
|
padding: "16px",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={yourLayoutStyle}>
|
||||||
|
{layout.zones.map((zone) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={zone.id} className="zone-area">
|
||||||
|
{/* 존 렌더링 */}
|
||||||
|
{renderer.renderZone(zone, zoneChildren)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 설정 옵션 추가
|
||||||
|
|
||||||
|
`config.ts`에서 레이아웃별 설정을 정의합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const YourLayoutConfig = {
|
||||||
|
defaultConfig: {
|
||||||
|
yourLayout: {
|
||||||
|
columns: 3, // 열 개수
|
||||||
|
rows: 2, // 행 개수
|
||||||
|
gap: 16, // 간격
|
||||||
|
aspectRatio: "16:9", // 비율
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: "8px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 타입 정의
|
||||||
|
|
||||||
|
`types.ts`에서 설정 타입을 정의합니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface YourLayoutConfig {
|
||||||
|
columns?: number;
|
||||||
|
rows?: number;
|
||||||
|
gap?: number;
|
||||||
|
aspectRatio?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
borderRadius?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YourLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: YourLayoutRenderer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 고급 설정
|
||||||
|
|
||||||
|
### 영역(Zone) 커스터마이징
|
||||||
|
|
||||||
|
영역별로 다른 스타일을 적용하려면:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 영역별 스타일 계산
|
||||||
|
const getZoneStyle = (zone: LayoutZone, index: number): React.CSSProperties => {
|
||||||
|
const baseStyle = {
|
||||||
|
backgroundColor: "#f8f9fa",
|
||||||
|
border: "1px solid #e9ecef",
|
||||||
|
borderRadius: "4px",
|
||||||
|
padding: "12px",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첫 번째 영역은 다른 스타일
|
||||||
|
if (index === 0) {
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
backgroundColor: "#e3f2fd",
|
||||||
|
gridColumn: "1 / -1", // 전체 너비
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseStyle;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 반응형 레이아웃
|
||||||
|
|
||||||
|
미디어 쿼리를 사용한 반응형 구현:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const getResponsiveStyle = (): React.CSSProperties => {
|
||||||
|
return {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(auto-fit, minmax(300px, 1fr))`,
|
||||||
|
gap: "16px",
|
||||||
|
// CSS-in-JS에서는 미디어 쿼리를 직접 사용할 수 없으므로
|
||||||
|
// CSS 클래스나 컨테이너 쿼리 사용 권장
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 애니메이션 추가
|
||||||
|
|
||||||
|
CSS 애니메이션을 포함한 레이아웃:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const animatedStyle: React.CSSProperties = {
|
||||||
|
transition: "all 0.3s ease-in-out",
|
||||||
|
opacity: isDesignMode ? 0.9 : 1,
|
||||||
|
transform: isDesignMode ? "scale(0.98)" : "scale(1)",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 자동 등록 시스템
|
||||||
|
|
||||||
|
### Hot Reload 지원
|
||||||
|
|
||||||
|
새 레이아웃은 다음과 같이 자동으로 등록됩니다:
|
||||||
|
|
||||||
|
1. **파일 저장 시**: Hot Reload로 즉시 반영
|
||||||
|
2. **자동 등록**: `AutoRegisteringLayoutRenderer` 상속으로 자동 등록
|
||||||
|
3. **즉시 사용**: 화면편집기에서 바로 사용 가능
|
||||||
|
|
||||||
|
### 수동 등록 (필요한 경우)
|
||||||
|
|
||||||
|
`lib/registry/layouts/index.ts`에 직접 추가:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 새 구조 레이아웃들 (자동 등록)
|
||||||
|
import "./your-layout/YourLayoutRenderer";
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 문제 해결
|
||||||
|
|
||||||
|
### 자주 발생하는 오류
|
||||||
|
|
||||||
|
#### 1. "Cannot read properties of undefined" 오류
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 문제: 설정이 없을 때 오류
|
||||||
|
const config = layout.layoutConfig.yourLayout.someProperty;
|
||||||
|
|
||||||
|
// ✅ 해결: 안전한 접근
|
||||||
|
const config = layout.layoutConfig.yourLayout?.someProperty || defaultValue;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. "React does not recognize prop" 경고
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 문제: 모든 props를 DOM에 전달
|
||||||
|
<div {...props}>
|
||||||
|
|
||||||
|
// ✅ 해결: DOM props만 전달
|
||||||
|
const { layout, isDesignMode, renderer, ...domProps } = props;
|
||||||
|
<div {...domProps}>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 레이아웃이 화면편집기에 나타나지 않음
|
||||||
|
|
||||||
|
1. **파일 저장 확인**: 모든 파일이 저장되었는지 확인
|
||||||
|
2. **자동 등록 확인**: `YourLayoutRenderer.registerSelf()` 호출 여부
|
||||||
|
3. **브라우저 새로고침**: 캐시 문제일 수 있음
|
||||||
|
4. **개발자 도구**: `window.__LAYOUT_REGISTRY__.list()` 로 등록 상태 확인
|
||||||
|
|
||||||
|
### 디버깅 도구
|
||||||
|
|
||||||
|
#### 브라우저 개발자 도구
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 등록된 레이아웃 목록 확인
|
||||||
|
window.__LAYOUT_REGISTRY__.list();
|
||||||
|
|
||||||
|
// 특정 레이아웃 정보 확인
|
||||||
|
window.__LAYOUT_REGISTRY__.get("your-layout");
|
||||||
|
|
||||||
|
// 레지스트리 통계
|
||||||
|
window.__LAYOUT_REGISTRY__.stats();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 예시: 완전한 레이아웃 생성
|
||||||
|
|
||||||
|
### 1. CLI로 생성
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/create-layout.js pricing-table --category=content --zones=4 --description="가격표 레이아웃" --author="개발팀"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 생성 결과
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 레이아웃 생성 완료!
|
||||||
|
📁 이름: pricingTable
|
||||||
|
🔖 ID: pricing-table
|
||||||
|
📂 카테고리: content
|
||||||
|
🎯 존 개수: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 커스터마이징
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PricingTableLayout.tsx
|
||||||
|
export const PricingTableLayout: React.FC<PricingTableLayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode,
|
||||||
|
renderer,
|
||||||
|
}) => {
|
||||||
|
const pricingTableStyle: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
|
||||||
|
gap: "24px",
|
||||||
|
padding: "32px",
|
||||||
|
backgroundColor: "#f8f9fa",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={pricingTableStyle}>
|
||||||
|
{layout.zones.map((zone, index) => (
|
||||||
|
<div
|
||||||
|
key={zone.id}
|
||||||
|
className={`pricing-card ${index === 1 ? 'featured' : ''}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "white",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "24px",
|
||||||
|
boxShadow: index === 1 ? "0 8px 32px rgba(0,0,0,0.1)" : "0 2px 8px rgba(0,0,0,0.1)",
|
||||||
|
border: index === 1 ? "2px solid #3b82f6" : "1px solid #e5e7eb",
|
||||||
|
transform: index === 1 ? "scale(1.05)" : "scale(1)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderer.renderZone(zone, renderer.getZoneChildren(zone.id))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 즉시 사용 가능
|
||||||
|
|
||||||
|
레이아웃이 자동으로 등록되어 화면편집기에서 바로 사용할 수 있습니다!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 마무리
|
||||||
|
|
||||||
|
새로운 CLI 방식으로 레이아웃 추가가 매우 간단해졌습니다:
|
||||||
|
|
||||||
|
1. **한 줄 명령어**로 모든 파일 자동 생성
|
||||||
|
2. **타입 안전성** 보장
|
||||||
|
3. **자동 등록**으로 즉시 사용 가능
|
||||||
|
4. **Hot Reload** 지원으로 빠른 개발
|
||||||
|
|
||||||
|
더 자세한 정보가 필요하면 각 레이아웃의 `README.md` 파일을 참고하세요! 🚀
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import {
|
||||||
|
LayoutStandard,
|
||||||
|
CreateLayoutRequest,
|
||||||
|
UpdateLayoutRequest,
|
||||||
|
GetLayoutsResponse,
|
||||||
|
LayoutCategory,
|
||||||
|
LayoutType,
|
||||||
|
} from "@/types/layout";
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface GetLayoutsParams {
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
category?: LayoutCategory;
|
||||||
|
layoutType?: LayoutType;
|
||||||
|
searchTerm?: string;
|
||||||
|
includePublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuplicateLayoutRequest {
|
||||||
|
newName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LayoutApiService {
|
||||||
|
private basePath = "/layouts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 목록 조회
|
||||||
|
*/
|
||||||
|
async getLayouts(params: GetLayoutsParams = {}): Promise<GetLayoutsResponse> {
|
||||||
|
const response = await apiClient.get(this.basePath, { params });
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 상세 조회
|
||||||
|
*/
|
||||||
|
async getLayoutById(layoutCode: string): Promise<LayoutStandard> {
|
||||||
|
const response = await apiClient.get(`${this.basePath}/${layoutCode}`);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 생성
|
||||||
|
*/
|
||||||
|
async createLayout(data: CreateLayoutRequest): Promise<LayoutStandard> {
|
||||||
|
const response = await apiClient.post(this.basePath, data);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 수정
|
||||||
|
*/
|
||||||
|
async updateLayout(layoutCode: string, data: Partial<CreateLayoutRequest>): Promise<LayoutStandard> {
|
||||||
|
const response = await apiClient.put(`${this.basePath}/${layoutCode}`, data);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 삭제
|
||||||
|
*/
|
||||||
|
async deleteLayout(layoutCode: string): Promise<void> {
|
||||||
|
await apiClient.delete(`${this.basePath}/${layoutCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 복제
|
||||||
|
*/
|
||||||
|
async duplicateLayout(layoutCode: string, data: DuplicateLayoutRequest): Promise<LayoutStandard> {
|
||||||
|
const response = await apiClient.post(`${this.basePath}/${layoutCode}/duplicate`, data);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 레이아웃 개수 조회
|
||||||
|
*/
|
||||||
|
async getLayoutCountsByCategory(): Promise<Record<string, number>> {
|
||||||
|
const response = await apiClient.get(`${this.basePath}/counts-by-category`);
|
||||||
|
return response.data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 검색
|
||||||
|
*/
|
||||||
|
async searchLayouts(
|
||||||
|
searchTerm: string,
|
||||||
|
params: Omit<GetLayoutsParams, "searchTerm"> = {},
|
||||||
|
): Promise<GetLayoutsResponse> {
|
||||||
|
return this.getLayouts({ ...params, searchTerm });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 레이아웃 조회
|
||||||
|
*/
|
||||||
|
async getLayoutsByCategory(
|
||||||
|
category: LayoutCategory,
|
||||||
|
params: Omit<GetLayoutsParams, "category"> = {},
|
||||||
|
): Promise<GetLayoutsResponse> {
|
||||||
|
return this.getLayouts({ ...params, category });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 타입별 조회
|
||||||
|
*/
|
||||||
|
async getLayoutsByType(
|
||||||
|
layoutType: LayoutType,
|
||||||
|
params: Omit<GetLayoutsParams, "layoutType"> = {},
|
||||||
|
): Promise<GetLayoutsResponse> {
|
||||||
|
return this.getLayouts({ ...params, layoutType });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공개 레이아웃만 조회
|
||||||
|
*/
|
||||||
|
async getPublicLayouts(params: Omit<GetLayoutsParams, "includePublic"> = {}): Promise<GetLayoutsResponse> {
|
||||||
|
return this.getLayouts({ ...params, includePublic: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개인 레이아웃만 조회
|
||||||
|
*/
|
||||||
|
async getPrivateLayouts(params: Omit<GetLayoutsParams, "includePublic"> = {}): Promise<GetLayoutsResponse> {
|
||||||
|
return this.getLayouts({ ...params, includePublic: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인스턴스 생성 및 내보내기
|
||||||
|
export const layoutApi = new LayoutApiService();
|
||||||
|
|
||||||
|
// 기본 내보내기
|
||||||
|
export default layoutApi;
|
||||||
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
|
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
|
||||||
|
|
||||||
// 컴포넌트 렌더러 인터페이스
|
// 컴포넌트 렌더러 인터페이스
|
||||||
export interface ComponentRenderer {
|
export interface ComponentRenderer {
|
||||||
|
|
@ -77,6 +78,21 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// component_config에서 실제 컴포넌트 타입 추출
|
// component_config에서 실제 컴포넌트 타입 추출
|
||||||
const componentType = component.componentConfig?.type || component.type;
|
const componentType = component.componentConfig?.type || component.type;
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트 처리
|
||||||
|
if (componentType === "layout") {
|
||||||
|
return (
|
||||||
|
<DynamicLayoutRenderer
|
||||||
|
layout={component as any}
|
||||||
|
allComponents={props.allComponents || []}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onClick={onClick}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🎯 DynamicComponentRenderer:", {
|
console.log("🎯 DynamicComponentRenderer:", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
componentType,
|
componentType,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutComponent, ComponentData } from "@/types/screen";
|
||||||
|
import { LayoutRegistry } from "./LayoutRegistry";
|
||||||
|
|
||||||
|
export interface DynamicLayoutRendererProps {
|
||||||
|
layout: LayoutComponent;
|
||||||
|
allComponents: ComponentData[];
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
onZoneClick?: (zoneId: string, e: React.MouseEvent) => void;
|
||||||
|
onComponentDrop?: (zoneId: string, component: ComponentData, e: React.DragEvent) => void;
|
||||||
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
|
onDragEnd?: (e: React.DragEvent) => void;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
|
||||||
|
layout,
|
||||||
|
allComponents,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
onZoneClick,
|
||||||
|
onComponentDrop,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}) => {
|
||||||
|
console.log("🎯 DynamicLayoutRenderer:", {
|
||||||
|
layoutId: layout.id,
|
||||||
|
layoutType: layout.layoutType,
|
||||||
|
zonesCount: layout.zones.length,
|
||||||
|
allComponentsCount: allComponents.length,
|
||||||
|
isDesignMode,
|
||||||
|
isSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 레지스트리에서 레이아웃 정의 조회
|
||||||
|
const layoutDefinition = LayoutRegistry.getLayout(layout.layoutType);
|
||||||
|
|
||||||
|
if (!layoutDefinition) {
|
||||||
|
console.warn(`⚠️ 등록되지 않은 레이아웃 타입: ${layout.layoutType}`);
|
||||||
|
|
||||||
|
// 폴백 렌더링 - 기본 플레이스홀더
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50 p-4 ${className || ""}`}
|
||||||
|
style={style}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2 text-sm font-medium text-gray-600">{layout.label || `레이아웃 ${layout.id}`}</div>
|
||||||
|
<div className="text-xs text-gray-400">미구현 레이아웃: {layout.layoutType}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트 가져오기
|
||||||
|
const LayoutComponent = layoutDefinition.component;
|
||||||
|
|
||||||
|
// 레이아웃 렌더링 실행
|
||||||
|
try {
|
||||||
|
return (
|
||||||
|
<LayoutComponent
|
||||||
|
layout={layout}
|
||||||
|
allComponents={allComponents}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onClick={onClick}
|
||||||
|
onZoneClick={onZoneClick}
|
||||||
|
onComponentDrop={onComponentDrop}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
className={className}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 레이아웃 렌더링 실패 (${layout.layoutType}):`, error);
|
||||||
|
|
||||||
|
// 오류 발생 시 폴백 렌더링
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex h-full w-full items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4 ${className || ""}`}
|
||||||
|
style={style}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-2 text-sm font-medium text-red-600">레이아웃 렌더링 오류</div>
|
||||||
|
<div className="text-xs text-red-400">
|
||||||
|
{layout.layoutType}: {error instanceof Error ? error.message : "알 수 없는 오류"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DynamicLayoutRenderer;
|
||||||
|
|
||||||
|
|
@ -0,0 +1,317 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutDefinition, LayoutType, LayoutCategory } from "@/types/layout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 레지스트리 클래스
|
||||||
|
* 동적으로 레이아웃 컴포넌트를 등록, 관리, 조회할 수 있는 중앙 레지스트리
|
||||||
|
*/
|
||||||
|
export class LayoutRegistry {
|
||||||
|
private static layouts = new Map<string, LayoutDefinition>();
|
||||||
|
private static eventListeners: Array<(event: LayoutRegistryEvent) => void> = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 등록
|
||||||
|
*/
|
||||||
|
static registerLayout(definition: LayoutDefinition): void {
|
||||||
|
this.layouts.set(definition.id, definition);
|
||||||
|
this.emitEvent({
|
||||||
|
type: "layout_registered",
|
||||||
|
data: definition,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
console.log(`✅ 레이아웃 등록: ${definition.id} (${definition.name})`);
|
||||||
|
|
||||||
|
// 개발자 도구 등록 (개발 모드에서만)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
this.registerGlobalDevTools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 등록 해제
|
||||||
|
*/
|
||||||
|
static unregisterLayout(id: string): void {
|
||||||
|
const definition = this.layouts.get(id);
|
||||||
|
if (definition) {
|
||||||
|
this.layouts.delete(id);
|
||||||
|
this.emitEvent({
|
||||||
|
type: "layout_unregistered",
|
||||||
|
data: definition,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
console.log(`❌ 레이아웃 등록 해제: ${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 조회
|
||||||
|
*/
|
||||||
|
static getLayout(layoutType: string): LayoutDefinition | undefined {
|
||||||
|
return this.layouts.get(layoutType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리별 레이아웃 조회
|
||||||
|
*/
|
||||||
|
static getLayoutsByCategory(category: LayoutCategory): LayoutDefinition[] {
|
||||||
|
return Array.from(this.layouts.values()).filter((layout) => layout.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 레이아웃 조회
|
||||||
|
*/
|
||||||
|
static getAllLayouts(): LayoutDefinition[] {
|
||||||
|
return Array.from(this.layouts.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 레이아웃만 조회
|
||||||
|
*/
|
||||||
|
static getActiveLayouts(): LayoutDefinition[] {
|
||||||
|
return Array.from(this.layouts.values()).filter((layout) => layout.isActive !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 검색
|
||||||
|
*/
|
||||||
|
static searchLayouts(query: string): LayoutDefinition[] {
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
return Array.from(this.layouts.values()).filter(
|
||||||
|
(layout) =>
|
||||||
|
layout.name.toLowerCase().includes(searchTerm) ||
|
||||||
|
layout.nameEng?.toLowerCase().includes(searchTerm) ||
|
||||||
|
layout.description?.toLowerCase().includes(searchTerm) ||
|
||||||
|
layout.tags?.some((tag) => tag.toLowerCase().includes(searchTerm)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 존재 여부 확인
|
||||||
|
*/
|
||||||
|
static hasLayout(layoutType: string): boolean {
|
||||||
|
return this.layouts.has(layoutType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록된 레이아웃 타입 목록
|
||||||
|
*/
|
||||||
|
static getRegisteredTypes(): string[] {
|
||||||
|
return Array.from(this.layouts.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 수정
|
||||||
|
*/
|
||||||
|
static updateLayout(id: string, updates: Partial<LayoutDefinition>): boolean {
|
||||||
|
const existing = this.layouts.get(id);
|
||||||
|
if (existing) {
|
||||||
|
const updated = { ...existing, ...updates };
|
||||||
|
this.layouts.set(id, updated);
|
||||||
|
this.emitEvent({
|
||||||
|
type: "layout_updated",
|
||||||
|
data: updated,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
console.log(`🔄 레이아웃 수정: ${id}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대량 레이아웃 등록
|
||||||
|
*/
|
||||||
|
static registerLayouts(definitions: LayoutDefinition[]): void {
|
||||||
|
definitions.forEach((definition) => {
|
||||||
|
this.registerLayout(definition);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레지스트리 초기화
|
||||||
|
*/
|
||||||
|
static clear(): void {
|
||||||
|
this.layouts.clear();
|
||||||
|
this.emitEvent({
|
||||||
|
type: "registry_cleared",
|
||||||
|
data: null,
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
console.log("🧹 레이아웃 레지스트리 초기화");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 리스너 등록
|
||||||
|
*/
|
||||||
|
static addEventListener(listener: (event: LayoutRegistryEvent) => void): void {
|
||||||
|
this.eventListeners.push(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 리스너 제거
|
||||||
|
*/
|
||||||
|
static removeEventListener(listener: (event: LayoutRegistryEvent) => void): void {
|
||||||
|
const index = this.eventListeners.indexOf(listener);
|
||||||
|
if (index > -1) {
|
||||||
|
this.eventListeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 발생
|
||||||
|
*/
|
||||||
|
private static emitEvent(event: LayoutRegistryEvent): void {
|
||||||
|
this.eventListeners.forEach((listener) => {
|
||||||
|
try {
|
||||||
|
listener(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 레지스트리 이벤트 리스너 오류:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레지스트리 상태 정보
|
||||||
|
*/
|
||||||
|
static getRegistryInfo(): {
|
||||||
|
totalLayouts: number;
|
||||||
|
activeLayouts: number;
|
||||||
|
categoryCounts: Record<LayoutCategory, number>;
|
||||||
|
registeredTypes: string[];
|
||||||
|
} {
|
||||||
|
const allLayouts = this.getAllLayouts();
|
||||||
|
const activeLayouts = this.getActiveLayouts();
|
||||||
|
|
||||||
|
const categoryCounts = allLayouts.reduce(
|
||||||
|
(acc, layout) => {
|
||||||
|
acc[layout.category] = (acc[layout.category] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<LayoutCategory, number>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalLayouts: allLayouts.length,
|
||||||
|
activeLayouts: activeLayouts.length,
|
||||||
|
categoryCounts,
|
||||||
|
registeredTypes: this.getRegisteredTypes(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개발자 도구를 브라우저 글로벌에 등록
|
||||||
|
*/
|
||||||
|
private static registerGlobalDevTools(): void {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
(window as any).__LAYOUT_REGISTRY__ = {
|
||||||
|
list: () => {
|
||||||
|
console.table(
|
||||||
|
this.getAllLayouts().map((l) => ({
|
||||||
|
ID: l.id,
|
||||||
|
Name: l.name,
|
||||||
|
Category: l.category,
|
||||||
|
Zones: l.defaultZones?.length || 0,
|
||||||
|
Tags: l.tags?.join(", ") || "none",
|
||||||
|
Version: l.metadata?.version || "1.0.0",
|
||||||
|
Active: l.isActive !== false ? "✅" : "❌",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return this.getAllLayouts();
|
||||||
|
},
|
||||||
|
|
||||||
|
get: (id: string) => {
|
||||||
|
const layout = this.getLayout(id);
|
||||||
|
if (layout) {
|
||||||
|
console.group(`📦 Layout: ${id}`);
|
||||||
|
console.log("Definition:", layout);
|
||||||
|
console.log("Component:", layout.component);
|
||||||
|
console.log("Config:", layout.defaultConfig);
|
||||||
|
console.log("Zones:", layout.defaultZones);
|
||||||
|
console.groupEnd();
|
||||||
|
return layout;
|
||||||
|
} else {
|
||||||
|
console.warn(`❌ Layout not found: ${id}`);
|
||||||
|
console.log("Available layouts:", this.getRegisteredTypes());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
stats: () => {
|
||||||
|
const info = this.getRegistryInfo();
|
||||||
|
console.group("📊 Layout Registry Statistics");
|
||||||
|
console.log(`📦 Total Layouts: ${info.totalLayouts}`);
|
||||||
|
console.log(`✅ Active Layouts: ${info.activeLayouts}`);
|
||||||
|
console.log("📂 Categories:", info.categoryCounts);
|
||||||
|
console.log("🏷️ Registered Types:", info.registeredTypes);
|
||||||
|
console.groupEnd();
|
||||||
|
return info;
|
||||||
|
},
|
||||||
|
|
||||||
|
search: (query: string) => {
|
||||||
|
const results = this.searchLayouts(query);
|
||||||
|
console.log(`🔍 Search results for "${query}":`, results);
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
|
||||||
|
categories: () => {
|
||||||
|
const byCategory = this.getAllLayouts().reduce(
|
||||||
|
(acc, layout) => {
|
||||||
|
if (!acc[layout.category]) acc[layout.category] = [];
|
||||||
|
acc[layout.category].push(layout.id);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, string[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.table(byCategory);
|
||||||
|
return byCategory;
|
||||||
|
},
|
||||||
|
|
||||||
|
clear: () => {
|
||||||
|
console.warn("🧹 Clearing layout registry...");
|
||||||
|
this.clear();
|
||||||
|
console.log("✅ Registry cleared");
|
||||||
|
},
|
||||||
|
|
||||||
|
reload: () => {
|
||||||
|
console.log("🔄 Registry reload not implemented yet");
|
||||||
|
console.log("Try refreshing the page or using HMR");
|
||||||
|
},
|
||||||
|
|
||||||
|
help: () => {
|
||||||
|
console.group("🛠️ Layout Registry DevTools Help");
|
||||||
|
console.log("__LAYOUT_REGISTRY__.list() - 모든 레이아웃 목록 표시");
|
||||||
|
console.log("__LAYOUT_REGISTRY__.get(id) - 특정 레이아웃 상세 정보");
|
||||||
|
console.log("__LAYOUT_REGISTRY__.stats() - 레지스트리 통계");
|
||||||
|
console.log("__LAYOUT_REGISTRY__.search(query) - 레이아웃 검색");
|
||||||
|
console.log("__LAYOUT_REGISTRY__.categories() - 카테고리별 레이아웃");
|
||||||
|
console.log("__LAYOUT_REGISTRY__.clear() - 레지스트리 초기화");
|
||||||
|
console.log("__LAYOUT_REGISTRY__.help() - 도움말");
|
||||||
|
console.groupEnd();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 첫 등록 시에만 안내 메시지 출력
|
||||||
|
if (!(window as any).__LAYOUT_REGISTRY_INITIALIZED__) {
|
||||||
|
console.log("🛠️ Layout Registry DevTools initialized!");
|
||||||
|
console.log("Use __LAYOUT_REGISTRY__.help() for available commands");
|
||||||
|
(window as any).__LAYOUT_REGISTRY_INITIALIZED__ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레지스트리 이벤트 타입
|
||||||
|
export interface LayoutRegistryEvent {
|
||||||
|
type: "layout_registered" | "layout_unregistered" | "layout_updated" | "registry_cleared";
|
||||||
|
data: LayoutDefinition | null;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전역 레이아웃 레지스트리 인스턴스 (싱글톤)
|
||||||
|
export const layoutRegistry = LayoutRegistry;
|
||||||
|
|
||||||
|
// 기본 내보내기
|
||||||
|
export default LayoutRegistry;
|
||||||
|
|
@ -0,0 +1,367 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
||||||
|
import { LayoutDefinition } from "@/types/layout";
|
||||||
|
import { LayoutRegistry } from "../LayoutRegistry";
|
||||||
|
import { ComponentData, LayoutComponent } from "@/types/screen";
|
||||||
|
import { LayoutZone } from "@/types/layout";
|
||||||
|
import React from "react";
|
||||||
|
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 등록 기능을 제공하는 베이스 레이아웃 렌더러
|
||||||
|
*
|
||||||
|
* 사용 방법:
|
||||||
|
* 1. 이 클래스를 상속받습니다
|
||||||
|
* 2. static layoutDefinition을 정의합니다
|
||||||
|
* 3. 파일을 import하면 자동으로 등록됩니다
|
||||||
|
*/
|
||||||
|
export class AutoRegisteringLayoutRenderer {
|
||||||
|
protected props: LayoutRendererProps;
|
||||||
|
|
||||||
|
constructor(props: LayoutRendererProps) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 - 각 구현 클래스에서 반드시 정의해야 함
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition: LayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 메서드 - 각 구현 클래스에서 오버라이드해야 함
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
throw new Error("render() method must be implemented by subclass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 컨테이너 스타일을 계산합니다.
|
||||||
|
*/
|
||||||
|
getLayoutContainerStyle(): React.CSSProperties {
|
||||||
|
const { layout, style: propStyle } = this.props;
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
width: layout.size.width,
|
||||||
|
height: layout.size.height,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
...propStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 커스텀 스타일 적용
|
||||||
|
if (layout.style) {
|
||||||
|
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 스타일을 CSS 스타일로 변환합니다.
|
||||||
|
*/
|
||||||
|
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
|
||||||
|
const cssStyle: React.CSSProperties = {};
|
||||||
|
|
||||||
|
if (componentStyle.backgroundColor) {
|
||||||
|
cssStyle.backgroundColor = componentStyle.backgroundColor;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderColor) {
|
||||||
|
cssStyle.borderColor = componentStyle.borderColor;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderWidth) {
|
||||||
|
cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderStyle) {
|
||||||
|
cssStyle.borderStyle = componentStyle.borderStyle;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderRadius) {
|
||||||
|
cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존별 자식 컴포넌트들을 분류합니다.
|
||||||
|
*/
|
||||||
|
getZoneChildren(zoneId: string): ComponentData[] {
|
||||||
|
return this.props.allComponents.filter((comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 존을 렌더링합니다.
|
||||||
|
*/
|
||||||
|
renderZone(
|
||||||
|
zone: LayoutZone,
|
||||||
|
zoneChildren: ComponentData[] = [],
|
||||||
|
additionalProps: Record<string, any> = {},
|
||||||
|
): React.ReactElement {
|
||||||
|
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
|
||||||
|
|
||||||
|
// 존 스타일 계산 - 항상 구역 경계 표시
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
position: "relative",
|
||||||
|
// 구역 경계 시각화 - 항상 표시
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "rgba(248, 250, 252, 0.5)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
...this.getZoneStyle(zone),
|
||||||
|
...additionalProps.style,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드일 때 더 강조된 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
zoneStyle.border = "2px dashed #cbd5e1";
|
||||||
|
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 호버 효과를 위한 추가 스타일
|
||||||
|
const dropZoneStyle: React.CSSProperties = {
|
||||||
|
minHeight: isDesignMode ? "60px" : "40px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
||||||
|
justifyContent: zoneChildren.length === 0 ? "flex-start" : "flex-start",
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "12px",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
padding: "8px",
|
||||||
|
position: "relative",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={zone.id}
|
||||||
|
className={`layout-zone ${additionalProps.className || ""}`}
|
||||||
|
style={zoneStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onZoneClick?.(zone.id, e);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const componentData = e.dataTransfer.getData("application/json");
|
||||||
|
if (componentData) {
|
||||||
|
try {
|
||||||
|
const component = JSON.parse(componentData);
|
||||||
|
onComponentDrop?.(zone.id, component, e);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컴포넌트 드롭 데이터 파싱 오류:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "#3b82f6";
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 0 0 2px rgba(59, 130, 246, 0.1)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
||||||
|
e.currentTarget.style.backgroundColor = isDesignMode
|
||||||
|
? "rgba(241, 245, 249, 0.8)"
|
||||||
|
: "rgba(248, 250, 252, 0.5)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
{...additionalProps}
|
||||||
|
>
|
||||||
|
{/* 존 라벨 */}
|
||||||
|
<div
|
||||||
|
className="zone-label"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-2px",
|
||||||
|
left: "8px",
|
||||||
|
backgroundColor: isDesignMode ? "#3b82f6" : "#64748b",
|
||||||
|
color: "white",
|
||||||
|
fontSize: "10px",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "0 0 4px 4px",
|
||||||
|
fontWeight: "500",
|
||||||
|
zIndex: 10,
|
||||||
|
opacity: isDesignMode ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{zone.name || zone.id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드롭존 */}
|
||||||
|
<div className="drop-zone" style={dropZoneStyle}>
|
||||||
|
{zoneChildren.length > 0 ? (
|
||||||
|
zoneChildren.map((child) => (
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
key={child.id}
|
||||||
|
component={child}
|
||||||
|
allComponents={this.props.allComponents}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-zone-indicator" style={{ textAlign: "center", opacity: 0.6 }}>
|
||||||
|
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존의 스타일을 계산합니다.
|
||||||
|
*/
|
||||||
|
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
|
||||||
|
if (zone.size) {
|
||||||
|
if (zone.size.width) {
|
||||||
|
style.width = typeof zone.size.width === "number" ? `${zone.size.width}px` : zone.size.width;
|
||||||
|
}
|
||||||
|
if (zone.size.height) {
|
||||||
|
style.height = typeof zone.size.height === "number" ? `${zone.size.height}px` : zone.size.height;
|
||||||
|
}
|
||||||
|
if (zone.size.minWidth) {
|
||||||
|
style.minWidth = typeof zone.size.minWidth === "number" ? `${zone.size.minWidth}px` : zone.size.minWidth;
|
||||||
|
}
|
||||||
|
if (zone.size.minHeight) {
|
||||||
|
style.minHeight = typeof zone.size.minHeight === "number" ? `${zone.size.minHeight}px` : zone.size.minHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 등록 상태 추적
|
||||||
|
*/
|
||||||
|
private static registeredLayouts = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스가 정의될 때 자동으로 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
static registerSelf(): void {
|
||||||
|
const definition = this.layoutDefinition;
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
console.error(`❌ ${this.name}: layoutDefinition이 정의되지 않았습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.registeredLayouts.has(definition.id)) {
|
||||||
|
console.warn(`⚠️ ${definition.id} 레이아웃이 이미 등록되어 있습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 레지스트리에 등록
|
||||||
|
LayoutRegistry.registerLayout(definition);
|
||||||
|
this.registeredLayouts.add(definition.id);
|
||||||
|
|
||||||
|
console.log(`✅ 자동 등록 완료: ${definition.id} (${definition.name})`);
|
||||||
|
|
||||||
|
// 개발 모드에서 추가 정보 출력
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log(`📦 ${definition.id}:`, {
|
||||||
|
name: definition.name,
|
||||||
|
category: definition.category,
|
||||||
|
zones: definition.defaultZones?.length || 0,
|
||||||
|
tags: definition.tags?.join(", ") || "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ ${definition.id} 레이아웃 등록 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 등록 해제 (개발 모드에서 Hot Reload용)
|
||||||
|
*/
|
||||||
|
static unregisterSelf(): void {
|
||||||
|
const definition = this.layoutDefinition;
|
||||||
|
|
||||||
|
if (definition && this.registeredLayouts.has(definition.id)) {
|
||||||
|
LayoutRegistry.unregisterLayout(definition.id);
|
||||||
|
this.registeredLayouts.delete(definition.id);
|
||||||
|
console.log(`🗑️ 등록 해제: ${definition.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hot Reload 지원 (개발 모드)
|
||||||
|
*/
|
||||||
|
static reloadSelf(): void {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
this.unregisterSelf();
|
||||||
|
this.registerSelf();
|
||||||
|
console.log(`🔄 Hot Reload: ${this.layoutDefinition?.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록된 레이아웃 목록 조회
|
||||||
|
*/
|
||||||
|
static getRegisteredLayouts(): string[] {
|
||||||
|
return Array.from(this.registeredLayouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 유효성 검사
|
||||||
|
*/
|
||||||
|
static validateDefinition(): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||||
|
const definition = this.layoutDefinition;
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errors: ["layoutDefinition이 정의되지 않았습니다."],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// 필수 필드 검사
|
||||||
|
if (!definition.id) errors.push("ID가 필요합니다.");
|
||||||
|
if (!definition.name) errors.push("이름이 필요합니다.");
|
||||||
|
if (!definition.component) errors.push("컴포넌트가 필요합니다.");
|
||||||
|
if (!definition.category) errors.push("카테고리가 필요합니다.");
|
||||||
|
|
||||||
|
// 권장사항 검사
|
||||||
|
if (!definition.description || definition.description.length < 10) {
|
||||||
|
warnings.push("설명은 10자 이상 권장됩니다.");
|
||||||
|
}
|
||||||
|
if (!definition.defaultZones || definition.defaultZones.length === 0) {
|
||||||
|
warnings.push("기본 존 정의가 권장됩니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개발 모드에서 Hot Module Replacement 지원
|
||||||
|
*/
|
||||||
|
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
||||||
|
// HMR API가 있는 경우 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
|
||||||
|
// 글로벌 Hot Reload 함수 등록
|
||||||
|
(window as any).__reloadLayout__ = (layoutId: string) => {
|
||||||
|
const layouts = AutoRegisteringLayoutRenderer.getRegisteredLayouts();
|
||||||
|
console.log(`🔄 Available layouts for reload:`, layouts);
|
||||||
|
|
||||||
|
// TODO: 특정 레이아웃만 리로드하는 로직 구현
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,367 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
||||||
|
import { LayoutDefinition } from "@/types/layout";
|
||||||
|
import { LayoutRegistry } from "../LayoutRegistry";
|
||||||
|
import { ComponentData, LayoutComponent } from "@/types/screen";
|
||||||
|
import { LayoutZone } from "@/types/layout";
|
||||||
|
import React from "react";
|
||||||
|
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 등록 기능을 제공하는 베이스 레이아웃 렌더러
|
||||||
|
*
|
||||||
|
* 사용 방법:
|
||||||
|
* 1. 이 클래스를 상속받습니다
|
||||||
|
* 2. static layoutDefinition을 정의합니다
|
||||||
|
* 3. 파일을 import하면 자동으로 등록됩니다
|
||||||
|
*/
|
||||||
|
export class AutoRegisteringLayoutRenderer {
|
||||||
|
protected props: LayoutRendererProps;
|
||||||
|
|
||||||
|
constructor(props: LayoutRendererProps) {
|
||||||
|
this.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 - 각 구현 클래스에서 반드시 정의해야 함
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition: LayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 메서드 - 각 구현 클래스에서 오버라이드해야 함
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
throw new Error("render() method must be implemented by subclass");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 컨테이너 스타일을 계산합니다.
|
||||||
|
*/
|
||||||
|
getLayoutContainerStyle(): React.CSSProperties {
|
||||||
|
const { layout, style: propStyle } = this.props;
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
width: layout.size.width,
|
||||||
|
height: layout.size.height,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
...propStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 커스텀 스타일 적용
|
||||||
|
if (layout.style) {
|
||||||
|
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트 스타일을 CSS 스타일로 변환합니다.
|
||||||
|
*/
|
||||||
|
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
|
||||||
|
const cssStyle: React.CSSProperties = {};
|
||||||
|
|
||||||
|
if (componentStyle.backgroundColor) {
|
||||||
|
cssStyle.backgroundColor = componentStyle.backgroundColor;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderColor) {
|
||||||
|
cssStyle.borderColor = componentStyle.borderColor;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderWidth) {
|
||||||
|
cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderStyle) {
|
||||||
|
cssStyle.borderStyle = componentStyle.borderStyle;
|
||||||
|
}
|
||||||
|
if (componentStyle.borderRadius) {
|
||||||
|
cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존별 자식 컴포넌트들을 분류합니다.
|
||||||
|
*/
|
||||||
|
getZoneChildren(zoneId: string): ComponentData[] {
|
||||||
|
return this.props.allComponents.filter((comp) => comp.parentId === this.props.layout.id && comp.zoneId === zoneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 존을 렌더링합니다.
|
||||||
|
*/
|
||||||
|
renderZone(
|
||||||
|
zone: LayoutZone,
|
||||||
|
zoneChildren: ComponentData[] = [],
|
||||||
|
additionalProps: Record<string, any> = {},
|
||||||
|
): React.ReactElement {
|
||||||
|
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
|
||||||
|
|
||||||
|
// 존 스타일 계산 - 항상 구역 경계 표시
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
position: "relative",
|
||||||
|
// 구역 경계 시각화 - 항상 표시
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "rgba(248, 250, 252, 0.5)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
...this.getZoneStyle(zone),
|
||||||
|
...additionalProps.style,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드일 때 더 강조된 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
zoneStyle.border = "2px dashed #cbd5e1";
|
||||||
|
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 호버 효과를 위한 추가 스타일
|
||||||
|
const dropZoneStyle: React.CSSProperties = {
|
||||||
|
minHeight: isDesignMode ? "60px" : "40px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
||||||
|
justifyContent: zoneChildren.length === 0 ? "flex-start" : "flex-start",
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "12px",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
padding: "8px",
|
||||||
|
position: "relative",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={zone.id}
|
||||||
|
className={`layout-zone ${additionalProps.className || ""}`}
|
||||||
|
style={zoneStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onZoneClick?.(zone.id, e);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
}}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const componentData = e.dataTransfer.getData("application/json");
|
||||||
|
if (componentData) {
|
||||||
|
try {
|
||||||
|
const component = JSON.parse(componentData);
|
||||||
|
onComponentDrop?.(zone.id, component, e);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("컴포넌트 드롭 데이터 파싱 오류:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "#3b82f6";
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||||
|
e.currentTarget.style.boxShadow = "0 0 0 2px rgba(59, 130, 246, 0.1)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
||||||
|
e.currentTarget.style.backgroundColor = isDesignMode
|
||||||
|
? "rgba(241, 245, 249, 0.8)"
|
||||||
|
: "rgba(248, 250, 252, 0.5)";
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
{...additionalProps}
|
||||||
|
>
|
||||||
|
{/* 존 라벨 */}
|
||||||
|
<div
|
||||||
|
className="zone-label"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-2px",
|
||||||
|
left: "8px",
|
||||||
|
backgroundColor: isDesignMode ? "#3b82f6" : "#64748b",
|
||||||
|
color: "white",
|
||||||
|
fontSize: "10px",
|
||||||
|
padding: "2px 6px",
|
||||||
|
borderRadius: "0 0 4px 4px",
|
||||||
|
fontWeight: "500",
|
||||||
|
zIndex: 10,
|
||||||
|
opacity: isDesignMode ? 1 : 0.7,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{zone.name || zone.id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 드롭존 */}
|
||||||
|
<div className="drop-zone" style={dropZoneStyle}>
|
||||||
|
{zoneChildren.length > 0 ? (
|
||||||
|
zoneChildren.map((child) => (
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
key={child.id}
|
||||||
|
component={child}
|
||||||
|
allComponents={this.props.allComponents}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-zone-indicator" style={{ textAlign: "center", opacity: 0.6 }}>
|
||||||
|
{isDesignMode ? "컴포넌트를 드래그하여 추가하세요" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존의 스타일을 계산합니다.
|
||||||
|
*/
|
||||||
|
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
|
||||||
|
if (zone.size) {
|
||||||
|
if (zone.size.width) {
|
||||||
|
style.width = typeof zone.size.width === "number" ? `${zone.size.width}px` : zone.size.width;
|
||||||
|
}
|
||||||
|
if (zone.size.height) {
|
||||||
|
style.height = typeof zone.size.height === "number" ? `${zone.size.height}px` : zone.size.height;
|
||||||
|
}
|
||||||
|
if (zone.size.minWidth) {
|
||||||
|
style.minWidth = typeof zone.size.minWidth === "number" ? `${zone.size.minWidth}px` : zone.size.minWidth;
|
||||||
|
}
|
||||||
|
if (zone.size.minHeight) {
|
||||||
|
style.minHeight = typeof zone.size.minHeight === "number" ? `${zone.size.minHeight}px` : zone.size.minHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 등록 상태 추적
|
||||||
|
*/
|
||||||
|
private static registeredLayouts = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스가 정의될 때 자동으로 레지스트리에 등록
|
||||||
|
*/
|
||||||
|
static registerSelf(): void {
|
||||||
|
const definition = this.layoutDefinition;
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
console.error(`❌ ${this.name}: layoutDefinition이 정의되지 않았습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.registeredLayouts.has(definition.id)) {
|
||||||
|
console.warn(`⚠️ ${definition.id} 레이아웃이 이미 등록되어 있습니다.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 레지스트리에 등록
|
||||||
|
LayoutRegistry.registerLayout(definition);
|
||||||
|
this.registeredLayouts.add(definition.id);
|
||||||
|
|
||||||
|
console.log(`✅ 자동 등록 완료: ${definition.id} (${definition.name})`);
|
||||||
|
|
||||||
|
// 개발 모드에서 추가 정보 출력
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
console.log(`📦 ${definition.id}:`, {
|
||||||
|
name: definition.name,
|
||||||
|
category: definition.category,
|
||||||
|
zones: definition.defaultZones?.length || 0,
|
||||||
|
tags: definition.tags?.join(", ") || "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ ${definition.id} 레이아웃 등록 실패:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 등록 해제 (개발 모드에서 Hot Reload용)
|
||||||
|
*/
|
||||||
|
static unregisterSelf(): void {
|
||||||
|
const definition = this.layoutDefinition;
|
||||||
|
|
||||||
|
if (definition && this.registeredLayouts.has(definition.id)) {
|
||||||
|
LayoutRegistry.unregisterLayout(definition.id);
|
||||||
|
this.registeredLayouts.delete(definition.id);
|
||||||
|
console.log(`🗑️ 등록 해제: ${definition.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hot Reload 지원 (개발 모드)
|
||||||
|
*/
|
||||||
|
static reloadSelf(): void {
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
this.unregisterSelf();
|
||||||
|
this.registerSelf();
|
||||||
|
console.log(`🔄 Hot Reload: ${this.layoutDefinition?.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록된 레이아웃 목록 조회
|
||||||
|
*/
|
||||||
|
static getRegisteredLayouts(): string[] {
|
||||||
|
return Array.from(this.registeredLayouts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 유효성 검사
|
||||||
|
*/
|
||||||
|
static validateDefinition(): { isValid: boolean; errors: string[]; warnings: string[] } {
|
||||||
|
const definition = this.layoutDefinition;
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return {
|
||||||
|
isValid: false,
|
||||||
|
errors: ["layoutDefinition이 정의되지 않았습니다."],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// 필수 필드 검사
|
||||||
|
if (!definition.id) errors.push("ID가 필요합니다.");
|
||||||
|
if (!definition.name) errors.push("이름이 필요합니다.");
|
||||||
|
if (!definition.component) errors.push("컴포넌트가 필요합니다.");
|
||||||
|
if (!definition.category) errors.push("카테고리가 필요합니다.");
|
||||||
|
|
||||||
|
// 권장사항 검사
|
||||||
|
if (!definition.description || definition.description.length < 10) {
|
||||||
|
warnings.push("설명은 10자 이상 권장됩니다.");
|
||||||
|
}
|
||||||
|
if (!definition.defaultZones || definition.defaultZones.length === 0) {
|
||||||
|
warnings.push("기본 존 정의가 권장됩니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개발 모드에서 Hot Module Replacement 지원
|
||||||
|
*/
|
||||||
|
if (process.env.NODE_ENV === "development" && typeof window !== "undefined") {
|
||||||
|
// HMR API가 있는 경우 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
|
||||||
|
// 글로벌 Hot Reload 함수 등록
|
||||||
|
(window as any).__reloadLayout__ = (layoutId: string) => {
|
||||||
|
const layouts = AutoRegisteringLayoutRenderer.getRegisteredLayouts();
|
||||||
|
console.log(`🔄 Available layouts for reload:`, layouts);
|
||||||
|
|
||||||
|
// TODO: 특정 레이아웃만 리로드하는 로직 구현
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,304 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentData, LayoutComponent } from "@/types/screen";
|
||||||
|
import { LayoutZone } from "@/types/layout";
|
||||||
|
import { DynamicComponentRenderer } from "../DynamicComponentRenderer";
|
||||||
|
|
||||||
|
export interface LayoutRendererProps {
|
||||||
|
layout: LayoutComponent;
|
||||||
|
allComponents: ComponentData[];
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
onZoneClick?: (zoneId: string, e: React.MouseEvent) => void;
|
||||||
|
onComponentDrop?: (zoneId: string, component: ComponentData, e: React.DragEvent) => void;
|
||||||
|
onDragStart?: (e: React.DragEvent) => void;
|
||||||
|
onDragEnd?: (e: React.DragEvent) => void;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererProps> {
|
||||||
|
abstract render(): React.ReactElement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 존을 렌더링합니다.
|
||||||
|
*/
|
||||||
|
protected renderZone(
|
||||||
|
zone: LayoutZone,
|
||||||
|
zoneChildren: ComponentData[] = [],
|
||||||
|
additionalProps: Record<string, any> = {},
|
||||||
|
): React.ReactElement {
|
||||||
|
const { isDesignMode, onZoneClick, onComponentDrop } = this.props;
|
||||||
|
|
||||||
|
// 존 스타일 계산 - 항상 구역 경계 표시
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
position: "relative",
|
||||||
|
// 구역 경계 시각화 - 항상 표시
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "rgba(248, 250, 252, 0.5)",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
...this.getZoneStyle(zone),
|
||||||
|
...additionalProps.style,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드일 때 더 강조된 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
zoneStyle.border = "2px dashed #cbd5e1";
|
||||||
|
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 호버 효과를 위한 추가 스타일
|
||||||
|
const dropZoneStyle: React.CSSProperties = {
|
||||||
|
minHeight: isDesignMode ? "60px" : "40px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: zoneChildren.length === 0 ? "center" : "stretch",
|
||||||
|
justifyContent: zoneChildren.length === 0 ? "center" : "flex-start",
|
||||||
|
color: "#64748b",
|
||||||
|
fontSize: "12px",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
padding: "8px",
|
||||||
|
position: "relative",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={zone.id}
|
||||||
|
className={`layout-zone ${isDesignMode ? "design-mode" : ""} ${additionalProps.className || ""}`}
|
||||||
|
data-zone-id={zone.id}
|
||||||
|
style={zoneStyle}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onZoneClick?.(zone.id, e);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
const element = e.currentTarget;
|
||||||
|
element.style.borderColor = "#3b82f6";
|
||||||
|
element.style.backgroundColor = "rgba(59, 130, 246, 0.02)";
|
||||||
|
element.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
const element = e.currentTarget;
|
||||||
|
element.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
||||||
|
element.style.backgroundColor = isDesignMode ? "rgba(241, 245, 249, 0.8)" : "rgba(248, 250, 252, 0.5)";
|
||||||
|
element.style.boxShadow = "none";
|
||||||
|
}}
|
||||||
|
onDrop={this.handleDrop(zone.id)}
|
||||||
|
onDragOver={this.handleDragOver}
|
||||||
|
onDragEnter={this.handleDragEnter}
|
||||||
|
onDragLeave={this.handleDragLeave}
|
||||||
|
>
|
||||||
|
{/* 구역 라벨 - 항상 표시 */}
|
||||||
|
<div
|
||||||
|
className="zone-label"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-12px",
|
||||||
|
left: "8px",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
border: "1px solid #e2e8f0",
|
||||||
|
borderRadius: "12px",
|
||||||
|
padding: "2px 8px",
|
||||||
|
fontSize: "10px",
|
||||||
|
fontWeight: "500",
|
||||||
|
color: "#6b7280",
|
||||||
|
zIndex: 10,
|
||||||
|
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{zone.name || zone.id}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="zone-content" style={dropZoneStyle}>
|
||||||
|
{zoneChildren.length === 0 && isDesignMode ? (
|
||||||
|
<div className="drop-placeholder">{zone.name}에 컴포넌트를 드롭하세요</div>
|
||||||
|
) : zoneChildren.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="empty-zone-indicator"
|
||||||
|
style={{
|
||||||
|
color: "#9ca3af",
|
||||||
|
fontSize: "11px",
|
||||||
|
textAlign: "center",
|
||||||
|
fontStyle: "italic",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
빈 구역
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
zoneChildren.map((child) => (
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
key={child.id}
|
||||||
|
component={child}
|
||||||
|
allComponents={this.props.allComponents}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존 스타일을 계산합니다.
|
||||||
|
*/
|
||||||
|
protected getZoneStyle(zone: LayoutZone): React.CSSProperties {
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
|
||||||
|
// 크기 설정
|
||||||
|
if (typeof zone.size.width === "number") {
|
||||||
|
style.width = `${zone.size.width}px`;
|
||||||
|
} else {
|
||||||
|
style.width = zone.size.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof zone.size.height === "number") {
|
||||||
|
style.height = `${zone.size.height}px`;
|
||||||
|
} else {
|
||||||
|
style.height = zone.size.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최소/최대 크기
|
||||||
|
if (zone.size.minWidth) style.minWidth = `${zone.size.minWidth}px`;
|
||||||
|
if (zone.size.minHeight) style.minHeight = `${zone.size.minHeight}px`;
|
||||||
|
if (zone.size.maxWidth) style.maxWidth = `${zone.size.maxWidth}px`;
|
||||||
|
if (zone.size.maxHeight) style.maxHeight = `${zone.size.maxHeight}px`;
|
||||||
|
|
||||||
|
// 커스텀 스타일 적용
|
||||||
|
if (zone.style) {
|
||||||
|
Object.assign(style, this.convertComponentStyleToCSS(zone.style));
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentStyle을 CSS 스타일로 변환합니다.
|
||||||
|
*/
|
||||||
|
protected convertComponentStyleToCSS(componentStyle: any): React.CSSProperties {
|
||||||
|
const cssStyle: React.CSSProperties = {};
|
||||||
|
|
||||||
|
// 여백
|
||||||
|
if (componentStyle.margin) cssStyle.margin = componentStyle.margin;
|
||||||
|
if (componentStyle.padding) cssStyle.padding = componentStyle.padding;
|
||||||
|
|
||||||
|
// 테두리
|
||||||
|
if (componentStyle.borderWidth) cssStyle.borderWidth = `${componentStyle.borderWidth}px`;
|
||||||
|
if (componentStyle.borderColor) cssStyle.borderColor = componentStyle.borderColor;
|
||||||
|
if (componentStyle.borderStyle) cssStyle.borderStyle = componentStyle.borderStyle;
|
||||||
|
if (componentStyle.borderRadius) cssStyle.borderRadius = `${componentStyle.borderRadius}px`;
|
||||||
|
|
||||||
|
// 배경
|
||||||
|
if (componentStyle.backgroundColor) cssStyle.backgroundColor = componentStyle.backgroundColor;
|
||||||
|
|
||||||
|
// 텍스트
|
||||||
|
if (componentStyle.color) cssStyle.color = componentStyle.color;
|
||||||
|
if (componentStyle.fontSize) cssStyle.fontSize = `${componentStyle.fontSize}px`;
|
||||||
|
if (componentStyle.fontWeight) cssStyle.fontWeight = componentStyle.fontWeight;
|
||||||
|
if (componentStyle.textAlign) cssStyle.textAlign = componentStyle.textAlign;
|
||||||
|
|
||||||
|
return cssStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 컨테이너 스타일을 계산합니다.
|
||||||
|
*/
|
||||||
|
protected getLayoutContainerStyle(): React.CSSProperties {
|
||||||
|
const { layout, style: propStyle } = this.props;
|
||||||
|
|
||||||
|
const style: React.CSSProperties = {
|
||||||
|
width: layout.size.width,
|
||||||
|
height: layout.size.height,
|
||||||
|
position: "relative",
|
||||||
|
overflow: "hidden",
|
||||||
|
...propStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 커스텀 스타일 적용
|
||||||
|
if (layout.style) {
|
||||||
|
Object.assign(style, this.convertComponentStyleToCSS(layout.style));
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존별 자식 컴포넌트들을 분류합니다.
|
||||||
|
*/
|
||||||
|
protected getZoneChildren(zoneId: string): ComponentData[] {
|
||||||
|
return this.props.allComponents.filter(
|
||||||
|
(component) => component.parentId === this.props.layout.id && (component as any).zoneId === zoneId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 드롭 핸들러
|
||||||
|
*/
|
||||||
|
private handleDrop = (zoneId: string) => (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 드롭존 하이라이트 제거
|
||||||
|
this.removeDragHighlight(e.currentTarget as HTMLElement);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const componentData = e.dataTransfer.getData("application/json");
|
||||||
|
if (componentData) {
|
||||||
|
const component = JSON.parse(componentData);
|
||||||
|
this.props.onComponentDrop?.(zoneId, component, e);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("드롭 데이터 파싱 오류:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 드롭존 하이라이트 추가
|
||||||
|
this.addDragHighlight(e.currentTarget as HTMLElement);
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleDragEnter = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// 실제로 존을 벗어났는지 확인
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
const x = e.clientX;
|
||||||
|
const y = e.clientY;
|
||||||
|
|
||||||
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||||
|
this.removeDragHighlight(e.currentTarget as HTMLElement);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 하이라이트 추가
|
||||||
|
*/
|
||||||
|
private addDragHighlight(element: HTMLElement) {
|
||||||
|
element.classList.add("drag-over");
|
||||||
|
element.style.borderColor = "#3b82f6";
|
||||||
|
element.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 드래그 하이라이트 제거
|
||||||
|
*/
|
||||||
|
private removeDragHighlight(element: HTMLElement) {
|
||||||
|
element.classList.remove("drag-over");
|
||||||
|
element.style.borderColor = "";
|
||||||
|
element.style.backgroundColor = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
||||||
|
|
||||||
|
export default class FlexboxLayoutRenderer extends BaseLayoutRenderer {
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
|
||||||
|
|
||||||
|
if (!layout.layoutConfig.flexbox) {
|
||||||
|
return <div className="error-layout">플렉스박스 설정이 없습니다.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const flexConfig = layout.layoutConfig.flexbox;
|
||||||
|
const containerStyle = this.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// 플렉스박스 스타일 설정
|
||||||
|
const flexStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: flexConfig.direction,
|
||||||
|
justifyContent: flexConfig.justify,
|
||||||
|
alignItems: flexConfig.align,
|
||||||
|
flexWrap: flexConfig.wrap,
|
||||||
|
gap: `${flexConfig.gap}px`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
flexStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
flexStyle.borderRadius = "8px";
|
||||||
|
flexStyle.padding = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flexbox-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
|
||||||
|
style={flexStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={this.props.onDragStart}
|
||||||
|
onDragEnd={this.props.onDragEnd}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone, index) => {
|
||||||
|
const zoneChildren = this.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
// 플렉스 아이템 스타일 설정
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
flex: this.calculateFlexValue(zone, flexConfig.direction),
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.renderZone(zone, zoneChildren, {
|
||||||
|
style: zoneStyle,
|
||||||
|
className: "flex-zone",
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 존이 없을 때 안내 메시지 */}
|
||||||
|
{layout.zones.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="empty-flex-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: isDesignMode ? "2px dashed #cbd5e1" : "1px solid #e2e8f0",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: isDesignMode ? "14px" : "12px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "100px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "#3b82f6";
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
||||||
|
e.currentTarget.style.backgroundColor = isDesignMode
|
||||||
|
? "rgba(148, 163, 184, 0.05)"
|
||||||
|
: "rgba(248, 250, 252, 0.5)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDesignMode ? "플렉스박스 레이아웃에 존을 추가하세요" : "빈 레이아웃"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플렉스 아이템의 flex 값을 계산합니다.
|
||||||
|
*/
|
||||||
|
private calculateFlexValue(zone: any, direction: string): string {
|
||||||
|
// 존의 크기에 따라 flex 값 결정
|
||||||
|
if (direction === "row" || direction === "row-reverse") {
|
||||||
|
// 가로 방향: width를 기준으로 flex 값 계산
|
||||||
|
if (typeof zone.size.width === "string") {
|
||||||
|
if (zone.size.width.includes("fr")) {
|
||||||
|
return zone.size.width.replace("fr", "");
|
||||||
|
} else if (zone.size.width.includes("%")) {
|
||||||
|
const percent = parseInt(zone.size.width.replace("%", ""));
|
||||||
|
return `0 0 ${percent}%`;
|
||||||
|
} else if (zone.size.width.includes("px")) {
|
||||||
|
return `0 0 ${zone.size.width}`;
|
||||||
|
}
|
||||||
|
} else if (typeof zone.size.width === "number") {
|
||||||
|
return `0 0 ${zone.size.width}px`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 세로 방향: height를 기준으로 flex 값 계산
|
||||||
|
if (typeof zone.size.height === "string") {
|
||||||
|
if (zone.size.height.includes("fr")) {
|
||||||
|
return zone.size.height.replace("fr", "");
|
||||||
|
} else if (zone.size.height.includes("%")) {
|
||||||
|
const percent = parseInt(zone.size.height.replace("%", ""));
|
||||||
|
return `0 0 ${percent}%`;
|
||||||
|
} else if (zone.size.height.includes("px")) {
|
||||||
|
return `0 0 ${zone.size.height}`;
|
||||||
|
}
|
||||||
|
} else if (typeof zone.size.height === "number") {
|
||||||
|
return `0 0 ${zone.size.height}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값: 균등 분할
|
||||||
|
return "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// React 컴포넌트로 래핑
|
||||||
|
export const FlexboxLayout: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new FlexboxLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
||||||
|
|
||||||
|
export default class GridLayoutRenderer extends BaseLayoutRenderer {
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
|
||||||
|
|
||||||
|
if (!layout.layoutConfig.grid) {
|
||||||
|
return <div className="error-layout">그리드 설정이 없습니다.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridConfig = layout.layoutConfig.grid;
|
||||||
|
const containerStyle = this.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// 그리드 스타일 설정
|
||||||
|
const gridStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
|
||||||
|
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
|
||||||
|
gap: `${gridConfig.gap}px`,
|
||||||
|
gridRowGap: gridConfig.rowGap ? `${gridConfig.rowGap}px` : undefined,
|
||||||
|
gridColumnGap: gridConfig.columnGap ? `${gridConfig.columnGap}px` : undefined,
|
||||||
|
gridAutoRows: gridConfig.autoRows,
|
||||||
|
gridAutoColumns: gridConfig.autoColumns,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
gridStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
gridStyle.borderRadius = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
|
||||||
|
style={gridStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={this.props.onDragStart}
|
||||||
|
onDragEnd={this.props.onDragEnd}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone) => {
|
||||||
|
const zoneChildren = this.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
// 그리드 위치 설정
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
gridRow: zone.position.row !== undefined ? zone.position.row + 1 : undefined,
|
||||||
|
gridColumn: zone.position.column !== undefined ? zone.position.column + 1 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.renderZone(zone, zoneChildren, {
|
||||||
|
style: zoneStyle,
|
||||||
|
className: "grid-zone",
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 디자인 모드에서 빈 그리드 셀 표시 */}
|
||||||
|
{isDesignMode && this.renderEmptyGridCells()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 그리드 셀들을 렌더링합니다.
|
||||||
|
*/
|
||||||
|
private renderEmptyGridCells(): React.ReactElement[] {
|
||||||
|
const { layout } = this.props;
|
||||||
|
const gridConfig = layout.layoutConfig.grid!;
|
||||||
|
const totalCells = gridConfig.rows * gridConfig.columns;
|
||||||
|
const occupiedCells = new Set(
|
||||||
|
layout.zones
|
||||||
|
.map((zone) =>
|
||||||
|
zone.position.row !== undefined && zone.position.column !== undefined
|
||||||
|
? zone.position.row * gridConfig.columns + zone.position.column
|
||||||
|
: -1,
|
||||||
|
)
|
||||||
|
.filter((index) => index >= 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyCells: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalCells; i++) {
|
||||||
|
if (!occupiedCells.has(i)) {
|
||||||
|
const row = Math.floor(i / gridConfig.columns);
|
||||||
|
const column = i % gridConfig.columns;
|
||||||
|
|
||||||
|
emptyCells.push(
|
||||||
|
<div
|
||||||
|
key={`empty-${i}`}
|
||||||
|
className="empty-grid-cell"
|
||||||
|
style={{
|
||||||
|
gridRow: row + 1,
|
||||||
|
gridColumn: column + 1,
|
||||||
|
border: isDesignMode ? "1px dashed #cbd5e1" : "1px solid #f1f5f9",
|
||||||
|
borderRadius: "4px",
|
||||||
|
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "#94a3b8",
|
||||||
|
minHeight: "40px",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||||
|
e.currentTarget.style.borderColor = "#3b82f6";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDesignMode
|
||||||
|
? "rgba(148, 163, 184, 0.05)"
|
||||||
|
: "rgba(248, 250, 252, 0.3)";
|
||||||
|
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#f1f5f9";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDesignMode ? `${row + 1},${column + 1}` : ""}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return emptyCells;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// React 컴포넌트로 래핑
|
||||||
|
export const GridLayout: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new GridLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from "react";
|
||||||
|
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
||||||
|
|
||||||
|
export default class SplitLayoutRenderer extends BaseLayoutRenderer {
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
|
||||||
|
|
||||||
|
if (!layout.layoutConfig.split) {
|
||||||
|
return <div className="error-layout">분할 레이아웃 설정이 없습니다.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SplitLayoutComponent
|
||||||
|
layout={layout}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onClick={onClick}
|
||||||
|
className={className}
|
||||||
|
renderer={this}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitLayoutComponentProps {
|
||||||
|
layout: any;
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
className?: string;
|
||||||
|
renderer: SplitLayoutRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SplitLayoutComponent: React.FC<SplitLayoutComponentProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
renderer,
|
||||||
|
}) => {
|
||||||
|
const splitConfig = layout.layoutConfig.split;
|
||||||
|
const [sizes, setSizes] = useState(splitConfig.ratio || [50, 50]);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// 분할 컨테이너 스타일
|
||||||
|
const splitStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: splitConfig.direction === "horizontal" ? "row" : "column",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
splitStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
splitStyle.borderRadius = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스플리터 드래그 핸들러
|
||||||
|
const handleSplitterDrag = useCallback(
|
||||||
|
(e: React.MouseEvent, index: number) => {
|
||||||
|
if (!splitConfig.resizable || !isDesignMode) return;
|
||||||
|
|
||||||
|
setIsDragging(true);
|
||||||
|
const startPos = splitConfig.direction === "horizontal" ? e.clientX : e.clientY;
|
||||||
|
const startSizes = [...sizes];
|
||||||
|
|
||||||
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||||
|
const currentPos = splitConfig.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY;
|
||||||
|
const delta = currentPos - startPos;
|
||||||
|
const containerSize =
|
||||||
|
splitConfig.direction === "horizontal"
|
||||||
|
? (e.currentTarget as HTMLElement).parentElement!.clientWidth
|
||||||
|
: (e.currentTarget as HTMLElement).parentElement!.clientHeight;
|
||||||
|
|
||||||
|
const deltaPercent = (delta / containerSize) * 100;
|
||||||
|
|
||||||
|
const newSizes = [...startSizes];
|
||||||
|
newSizes[index] = Math.max(splitConfig.minSize?.[index] || 10, Math.min(90, startSizes[index] + deltaPercent));
|
||||||
|
newSizes[index + 1] = Math.max(
|
||||||
|
splitConfig.minSize?.[index + 1] || 10,
|
||||||
|
Math.min(90, startSizes[index + 1] - deltaPercent),
|
||||||
|
);
|
||||||
|
|
||||||
|
setSizes(newSizes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
},
|
||||||
|
[splitConfig, sizes, isDesignMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`split-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
|
||||||
|
style={splitStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode && !isDragging}
|
||||||
|
onDragStart={(e) => !isDragging && renderer.props.onDragStart?.(e)}
|
||||||
|
onDragEnd={(e) => !isDragging && renderer.props.onDragEnd?.(e)}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any, index: number) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
const isHorizontal = splitConfig.direction === "horizontal";
|
||||||
|
|
||||||
|
// 패널 크기 계산
|
||||||
|
const panelSize = sizes[index] || 100 / layout.zones.length;
|
||||||
|
const panelStyle: React.CSSProperties = {
|
||||||
|
[isHorizontal ? "width" : "height"]: `${panelSize}%`,
|
||||||
|
[isHorizontal ? "height" : "width"]: "100%",
|
||||||
|
overflow: "auto",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={zone.id}>
|
||||||
|
{/* 패널 */}
|
||||||
|
{renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: panelStyle,
|
||||||
|
className: "split-panel",
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 스플리터 (마지막 패널 제외) */}
|
||||||
|
{index < layout.zones.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`splitter ${isHorizontal ? "horizontal" : "vertical"} ${isDragging ? "dragging" : ""}`}
|
||||||
|
style={{
|
||||||
|
[isHorizontal ? "width" : "height"]: `${splitConfig.splitterSize || 4}px`,
|
||||||
|
[isHorizontal ? "height" : "width"]: "100%",
|
||||||
|
backgroundColor: "#e2e8f0",
|
||||||
|
cursor: isHorizontal ? "col-resize" : "row-resize",
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 10,
|
||||||
|
...(isDragging && {
|
||||||
|
backgroundColor: "#3b82f6",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => handleSplitterDrag(e, index)}
|
||||||
|
>
|
||||||
|
{/* 스플리터 핸들 */}
|
||||||
|
<div
|
||||||
|
className="splitter-handle"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "50%",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
[isHorizontal ? "width" : "height"]: "20px",
|
||||||
|
[isHorizontal ? "height" : "width"]: "4px",
|
||||||
|
backgroundColor: "#94a3b8",
|
||||||
|
borderRadius: "2px",
|
||||||
|
opacity: splitConfig.resizable && isDesignMode ? 1 : 0,
|
||||||
|
transition: "opacity 0.2s ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 디자인 모드에서 존이 없을 때 안내 메시지 */}
|
||||||
|
{isDesignMode && layout.zones.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="empty-split-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "2px dashed #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "rgba(148, 163, 184, 0.05)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "100px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
분할 레이아웃에 존을 추가하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// React 컴포넌트로 래핑
|
||||||
|
export const SplitLayout: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new SplitLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import { BaseLayoutRenderer, LayoutRendererProps } from "./BaseLayoutRenderer";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export default class TabsLayoutRenderer extends BaseLayoutRenderer {
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { layout, isDesignMode, isSelected, onClick, className } = this.props;
|
||||||
|
|
||||||
|
if (!layout.layoutConfig.tabs) {
|
||||||
|
return <div className="error-layout">탭 레이아웃 설정이 없습니다.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsLayoutComponent
|
||||||
|
layout={layout}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onClick={onClick}
|
||||||
|
className={className}
|
||||||
|
renderer={this}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabsLayoutComponentProps {
|
||||||
|
layout: any;
|
||||||
|
isDesignMode?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
className?: string;
|
||||||
|
renderer: TabsLayoutRenderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabsLayoutComponent: React.FC<TabsLayoutComponentProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode,
|
||||||
|
isSelected,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
renderer,
|
||||||
|
}) => {
|
||||||
|
const tabsConfig = layout.layoutConfig.tabs;
|
||||||
|
const [activeTab, setActiveTab] = useState(tabsConfig.defaultTab || layout.zones[0]?.id || "");
|
||||||
|
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// 탭 컨테이너 스타일
|
||||||
|
const tabsStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: tabsConfig.position === "left" || tabsConfig.position === "right" ? "row" : "column",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
tabsStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
tabsStyle.borderRadius = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 탭 사이즈 클래스
|
||||||
|
const sizeClass = tabsConfig.size === "sm" ? "text-sm" : tabsConfig.size === "lg" ? "text-lg" : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`tabs-layout ${isDesignMode ? "design-mode" : ""} ${className || ""}`}
|
||||||
|
style={tabsStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={renderer.props.onDragStart}
|
||||||
|
onDragEnd={renderer.props.onDragEnd}
|
||||||
|
>
|
||||||
|
{layout.zones.length === 0 ? (
|
||||||
|
/* 디자인 모드에서 존이 없을 때 안내 메시지 */
|
||||||
|
isDesignMode && (
|
||||||
|
<div
|
||||||
|
className="empty-tabs-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "2px dashed #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "rgba(148, 163, 184, 0.05)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "200px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
탭 레이아웃에 존을 추가하세요
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
orientation={tabsConfig.position === "left" || tabsConfig.position === "right" ? "vertical" : "horizontal"}
|
||||||
|
className="flex h-full w-full flex-col"
|
||||||
|
>
|
||||||
|
{/* 탭 목록 */}
|
||||||
|
<TabsList
|
||||||
|
className={` ${sizeClass} ${tabsConfig.position === "bottom" ? "order-2" : ""} ${tabsConfig.position === "right" ? "order-2" : ""} ${tabsConfig.variant === "pills" ? "bg-gray-100" : ""} ${tabsConfig.variant === "underline" ? "border-b" : ""} `}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
justifyContent:
|
||||||
|
tabsConfig.position === "left" || tabsConfig.position === "right" ? "flex-start" : "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any) => (
|
||||||
|
<div key={zone.id} className="flex items-center">
|
||||||
|
<TabsTrigger
|
||||||
|
value={zone.id}
|
||||||
|
className={` ${sizeClass} ${tabsConfig.variant === "pills" ? "rounded-full" : ""} ${tabsConfig.variant === "underline" ? "border-b-2 border-transparent data-[state=active]:border-blue-500" : ""} `}
|
||||||
|
>
|
||||||
|
{zone.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
|
||||||
|
{/* 닫기 버튼 (설정에서 허용한 경우) */}
|
||||||
|
{tabsConfig.closable && isDesignMode && layout.zones.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="ml-1 rounded p-1 hover:bg-gray-200"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// 탭 닫기 로직 (실제 구현 시 필요)
|
||||||
|
console.log("탭 닫기:", zone.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 탭 컨텐츠 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{layout.zones.map((zone: any) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent
|
||||||
|
key={zone.id}
|
||||||
|
value={zone.id}
|
||||||
|
className="h-full p-2"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: "6px",
|
||||||
|
backgroundColor: "rgba(248, 250, 252, 0.3)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: {
|
||||||
|
height: "100%",
|
||||||
|
minHeight: "100px",
|
||||||
|
},
|
||||||
|
className: "tab-panel",
|
||||||
|
})}
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// React 컴포넌트로 래핑
|
||||||
|
export const TabsLayout: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new TabsLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* accordion 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface AccordionLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // AccordionLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AccordionLayout: React.FC<AccordionLayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
renderer,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (!layout.layoutConfig.accordion) {
|
||||||
|
return (
|
||||||
|
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
<div className="font-medium">accordion 설정이 없습니다.</div>
|
||||||
|
<div className="mt-1 text-sm">layoutConfig.accordion가 필요합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accordionConfig = layout.layoutConfig.accordion || { defaultExpanded: [], multiple: false };
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// accordion 컨테이너 스타일
|
||||||
|
const accordionStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
accordionStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
accordionStyle.borderRadius = "8px";
|
||||||
|
accordionStyle.padding = "4px";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`accordion-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
|
||||||
|
style={accordionStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={props.onDragStart}
|
||||||
|
onDragEnd={props.onDragEnd}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any, index: number) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
const isExpanded = accordionConfig.defaultExpanded?.includes(zone.id) || index === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionSection
|
||||||
|
key={zone.id}
|
||||||
|
zone={zone}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
renderer={renderer}
|
||||||
|
zoneChildren={zoneChildren}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 디자인 모드에서 빈 영역 표시 */}
|
||||||
|
{layout.zones.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="empty-accordion-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "2px dashed #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "rgba(148, 163, 184, 0.05)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "100px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDesignMode ? "아코디언에 존을 추가하세요" : "빈 아코디언"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아코디언 섹션 컴포넌트
|
||||||
|
*/
|
||||||
|
const AccordionSection: React.FC<{
|
||||||
|
zone: any;
|
||||||
|
isExpanded: boolean;
|
||||||
|
isDesignMode: boolean;
|
||||||
|
renderer: any;
|
||||||
|
zoneChildren: any;
|
||||||
|
}> = ({ zone, isExpanded: initialExpanded, isDesignMode, renderer, zoneChildren }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(initialExpanded);
|
||||||
|
|
||||||
|
const headerStyle: React.CSSProperties = {
|
||||||
|
padding: "12px 16px",
|
||||||
|
backgroundColor: "#f8fafc",
|
||||||
|
borderBottom: "1px solid #e2e8f0",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: 500,
|
||||||
|
borderRadius: isDesignMode ? "4px" : "0",
|
||||||
|
margin: isDesignMode ? "2px" : "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentStyle: React.CSSProperties = {
|
||||||
|
padding: isExpanded ? "12px 16px" : "0 16px",
|
||||||
|
maxHeight: isExpanded ? "500px" : "0",
|
||||||
|
overflow: "hidden",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
borderRadius: isDesignMode ? "4px" : "0",
|
||||||
|
margin: isDesignMode ? "2px" : "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="accordion-section" style={{ borderRadius: isDesignMode ? "6px" : "0" }}>
|
||||||
|
<div className="accordion-header" style={headerStyle} onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
<span>{zone.name}</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
||||||
|
transition: "transform 0.2s ease",
|
||||||
|
fontSize: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="accordion-content" style={contentStyle}>
|
||||||
|
{renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: { minHeight: isExpanded ? "50px" : "0" },
|
||||||
|
className: "accordion-zone-content",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { AccordionLayoutDefinition } from "./index";
|
||||||
|
import { AccordionLayout } from "./AccordionLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* accordion 렌더러 (새 구조)
|
||||||
|
*/
|
||||||
|
export class AccordionLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 (자동 등록용)
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition = AccordionLayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스 로드 시 자동 등록 실행
|
||||||
|
*/
|
||||||
|
static {
|
||||||
|
this.registerSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 실행
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <AccordionLayout {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React 함수 컴포넌트로 래핑 (외부 사용용)
|
||||||
|
*/
|
||||||
|
export const AccordionLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new AccordionLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 모드에서 Hot Reload 지원
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// HMR API 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
(module as any).hot.dispose(() => {
|
||||||
|
AccordionLayoutRenderer.unregisterSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# accordion
|
||||||
|
|
||||||
|
accordion 레이아웃입니다.
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `AccordionLayout.tsx`: 메인 레이아웃 컴포넌트
|
||||||
|
- `AccordionLayoutRenderer.tsx`: 렌더러 (자동 등록)
|
||||||
|
- `config.ts`: 기본 설정
|
||||||
|
- `types.ts`: 타입 정의
|
||||||
|
- `index.ts`: 진입점
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
1. `AccordionLayout.tsx`에서 레이아웃 로직 구현
|
||||||
|
2. `config.ts`에서 기본 설정 조정
|
||||||
|
3. `types.ts`에서 타입 정의 추가
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
accordion: {
|
||||||
|
// TODO: 설정 옵션 문서화
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 존 구성
|
||||||
|
|
||||||
|
- **존 1** (`zone1`): 기본 영역
|
||||||
|
- **존 2** (`zone2`): 기본 영역
|
||||||
|
- **존 3** (`zone3`): 기본 영역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
생성일: 2025. 9. 10.
|
||||||
|
버전: 1.0.0
|
||||||
|
작성자: Developer
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* accordion 기본 설정
|
||||||
|
*/
|
||||||
|
export const AccordionLayoutConfig = {
|
||||||
|
defaultConfig: {
|
||||||
|
accordion: {
|
||||||
|
// TODO: 레이아웃 전용 설정 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing: 16,
|
||||||
|
// orientation: "vertical",
|
||||||
|
// allowResize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "존 3",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 설정 스키마 (검증용)
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
accordion: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
// TODO: 설정 스키마 정의
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["accordion"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||||||
|
import { AccordionLayout } from "./AccordionLayout";
|
||||||
|
import { AccordionLayoutRenderer } from "./AccordionLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 아코디언 레이아웃 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||||||
|
*/
|
||||||
|
const AccordionLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new AccordionLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* accordion 레이아웃 정의
|
||||||
|
*/
|
||||||
|
export const AccordionLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "accordion",
|
||||||
|
name: "아코디언 레이아웃",
|
||||||
|
nameEng: "Accordion Layout",
|
||||||
|
description: "접을 수 있는 아코디언 레이아웃입니다.",
|
||||||
|
category: "navigation",
|
||||||
|
icon: "accordion",
|
||||||
|
component: AccordionLayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
accordion: {
|
||||||
|
multiple: false,
|
||||||
|
defaultExpanded: ["zone1"],
|
||||||
|
collapsible: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "존 3",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: ["accordion", "navigation", "layout"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "Developer",
|
||||||
|
documentation: "accordion 레이아웃입니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 등록을 위한 export
|
||||||
|
export { AccordionLayout } from "./AccordionLayout";
|
||||||
|
export { AccordionLayoutRenderer } from "./AccordionLayoutRenderer";
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* accordion 설정 타입
|
||||||
|
*/
|
||||||
|
export interface AccordionConfig {
|
||||||
|
// TODO: 레이아웃 전용 설정 타입 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing?: number;
|
||||||
|
// orientation?: "vertical" | "horizontal";
|
||||||
|
// allowResize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* accordion Props 타입
|
||||||
|
*/
|
||||||
|
export interface AccordionLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // AccordionLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* accordion 존 타입
|
||||||
|
*/
|
||||||
|
export interface AccordionZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// TODO: 존별 전용 속성 정의
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 레이아웃 컴포넌트
|
||||||
|
* 3x2 격자로 구성된 카드 대시보드 레이아웃
|
||||||
|
*/
|
||||||
|
export const CardLayoutLayout: React.FC<LayoutRendererProps> = ({
|
||||||
|
layout,
|
||||||
|
children,
|
||||||
|
onUpdateLayout,
|
||||||
|
onSelectComponent,
|
||||||
|
isDesignMode = false,
|
||||||
|
}) => {
|
||||||
|
const cardConfig = layout.layoutConfig?.cardLayout || {
|
||||||
|
columns: 3,
|
||||||
|
gap: 16,
|
||||||
|
aspectRatio: "4:3",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드 레이아웃 스타일
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cardConfig.columns}, 1fr)`,
|
||||||
|
gridTemplateRows: "repeat(2, 300px)", // 2행 고정
|
||||||
|
gap: `${cardConfig.gap}px`,
|
||||||
|
padding: "16px",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
background: "transparent",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드 스타일
|
||||||
|
const cardStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "white",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "16px",
|
||||||
|
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
|
minHeight: "200px",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드에서 호버 효과
|
||||||
|
const designModeCardStyle: React.CSSProperties = isDesignMode
|
||||||
|
? {
|
||||||
|
...cardStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: "#d1d5db",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "#3b82f6",
|
||||||
|
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: cardStyle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={containerStyle}>
|
||||||
|
{layout.zones?.map((zone, index) => {
|
||||||
|
const zoneChildren = children?.filter((child) => child.props.parentId === zone.id) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={zone.id}
|
||||||
|
style={designModeCardStyle}
|
||||||
|
onClick={() => isDesignMode && onSelectComponent?.(zone.id)}
|
||||||
|
className={isDesignMode ? "hover:border-blue-500 hover:shadow-md" : ""}
|
||||||
|
>
|
||||||
|
{/* 카드 헤더 */}
|
||||||
|
{isDesignMode && (
|
||||||
|
<div className="absolute top-2 left-2 z-10">
|
||||||
|
<div className="rounded bg-blue-500 px-2 py-1 text-xs text-white">{zone.name}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카드 내용 */}
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
{zoneChildren.length > 0 ? (
|
||||||
|
<div className="flex-1">{zoneChildren}</div>
|
||||||
|
) : (
|
||||||
|
isDesignMode && (
|
||||||
|
<div className="flex flex-1 items-center justify-center rounded border-2 border-dashed border-gray-200 text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-sm font-medium">{zone.name}</div>
|
||||||
|
<div className="mt-1 text-xs">컴포넌트를 드래그하여 추가하세요</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||||||
|
import { CardLayoutDefinition } from "./index";
|
||||||
|
import { CardLayoutLayout } from "./CardLayoutLayout";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 레이아웃 렌더러
|
||||||
|
* AutoRegisteringLayoutRenderer를 상속받아 자동 등록 기능 제공
|
||||||
|
*/
|
||||||
|
export class CardLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 (자동 등록용)
|
||||||
|
*/
|
||||||
|
static layoutDefinition = CardLayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 레이아웃 렌더링
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
const { layout, children, onUpdateLayout, onSelectComponent, isDesignMode } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardLayoutLayout
|
||||||
|
layout={layout}
|
||||||
|
children={children}
|
||||||
|
onUpdateLayout={onUpdateLayout}
|
||||||
|
onSelectComponent={onSelectComponent}
|
||||||
|
isDesignMode={isDesignMode}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 컨테이너 스타일 계산
|
||||||
|
*/
|
||||||
|
getCardContainerStyle(): React.CSSProperties {
|
||||||
|
const cardConfig = this.props.layout.layoutConfig?.cardLayout || {
|
||||||
|
columns: 3,
|
||||||
|
gap: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cardConfig.columns}, 1fr)`,
|
||||||
|
gridTemplateRows: "repeat(2, 300px)",
|
||||||
|
gap: `${cardConfig.gap}px`,
|
||||||
|
padding: "16px",
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 카드 스타일 계산
|
||||||
|
*/
|
||||||
|
getCardStyle(zoneId: string): React.CSSProperties {
|
||||||
|
const baseStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: "white",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: "8px",
|
||||||
|
padding: "16px",
|
||||||
|
boxShadow: "0 1px 3px 0 rgba(0, 0, 0, 0.1)",
|
||||||
|
transition: "all 0.2s ease-in-out",
|
||||||
|
overflow: "hidden",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
position: "relative",
|
||||||
|
minHeight: "200px",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드에서 추가 스타일
|
||||||
|
if (this.props.isDesignMode) {
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
cursor: "pointer",
|
||||||
|
borderColor: "#d1d5db",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 호버 효과 계산
|
||||||
|
*/
|
||||||
|
getCardHoverStyle(): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
borderColor: "#3b82f6",
|
||||||
|
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1)",
|
||||||
|
transform: "translateY(-1px)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 위치 계산
|
||||||
|
*/
|
||||||
|
getGridPosition(index: number): { row: number; column: number } {
|
||||||
|
const columns = this.props.layout.layoutConfig?.cardLayout?.columns || 3;
|
||||||
|
return {
|
||||||
|
row: Math.floor(index / columns),
|
||||||
|
column: index % columns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
CardLayoutRenderer.registerSelf();
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
# card-layout
|
||||||
|
|
||||||
|
카드 형태의 대시보드 레이아웃입니다
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `Card-layoutLayout.tsx`: 메인 레이아웃 컴포넌트
|
||||||
|
- `Card-layoutLayoutRenderer.tsx`: 렌더러 (자동 등록)
|
||||||
|
- `config.ts`: 기본 설정
|
||||||
|
- `types.ts`: 타입 정의
|
||||||
|
- `index.ts`: 진입점
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
1. `Card-layoutLayout.tsx`에서 레이아웃 로직 구현
|
||||||
|
2. `config.ts`에서 기본 설정 조정
|
||||||
|
3. `types.ts`에서 타입 정의 추가
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
card-layout: {
|
||||||
|
// TODO: 설정 옵션 문서화
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 존 구성
|
||||||
|
|
||||||
|
- **존 1** (`zone1`): 기본 영역
|
||||||
|
- **존 2** (`zone2`): 기본 영역
|
||||||
|
- **존 3** (`zone3`): 기본 영역
|
||||||
|
- **존 4** (`zone4`): 기본 영역
|
||||||
|
- **존 5** (`zone5`): 기본 영역
|
||||||
|
- **존 6** (`zone6`): 기본 영역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
생성일: 2025. 9. 10.
|
||||||
|
버전: 1.0.0
|
||||||
|
작성자: 개발자
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* card-layout 기본 설정
|
||||||
|
*/
|
||||||
|
export const Card-layoutLayoutConfig = {
|
||||||
|
defaultConfig: {
|
||||||
|
card-layout: {
|
||||||
|
// TODO: 레이아웃 전용 설정 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing: 16,
|
||||||
|
// orientation: "vertical",
|
||||||
|
// allowResize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "존 3",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone4",
|
||||||
|
name: "존 4",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone5",
|
||||||
|
name: "존 5",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone6",
|
||||||
|
name: "존 6",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 설정 스키마 (검증용)
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
card-layout: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
// TODO: 설정 스키마 정의
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["card-layout"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||||||
|
import { CardLayoutRenderer } from "./CardLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 레이아웃 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||||||
|
*/
|
||||||
|
const CardLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new CardLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* card-layout 레이아웃 정의
|
||||||
|
*/
|
||||||
|
export const CardLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "card-layout",
|
||||||
|
name: "카드 레이아웃",
|
||||||
|
nameEng: "Card Layout",
|
||||||
|
description: "카드 형태의 대시보드 레이아웃입니다",
|
||||||
|
category: "dashboard",
|
||||||
|
icon: "grid-3x3",
|
||||||
|
component: CardLayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
cardLayout: {
|
||||||
|
columns: 3,
|
||||||
|
gap: 16,
|
||||||
|
aspectRatio: "4:3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "card1",
|
||||||
|
name: "카드 1",
|
||||||
|
position: { row: 0, column: 0 },
|
||||||
|
size: { width: "33.33%", height: "300px" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "card2",
|
||||||
|
name: "카드 2",
|
||||||
|
position: { row: 0, column: 1 },
|
||||||
|
size: { width: "33.33%", height: "300px" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "card3",
|
||||||
|
name: "카드 3",
|
||||||
|
position: { row: 0, column: 2 },
|
||||||
|
size: { width: "33.33%", height: "300px" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "card4",
|
||||||
|
name: "카드 4",
|
||||||
|
position: { row: 1, column: 0 },
|
||||||
|
size: { width: "33.33%", height: "300px" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "card5",
|
||||||
|
name: "카드 5",
|
||||||
|
position: { row: 1, column: 1 },
|
||||||
|
size: { width: "33.33%", height: "300px" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "card6",
|
||||||
|
name: "카드 6",
|
||||||
|
position: { row: 1, column: 2 },
|
||||||
|
size: { width: "33.33%", height: "300px" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: ["card-layout", "dashboard", "layout"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발자",
|
||||||
|
documentation: "카드 형태의 대시보드 레이아웃입니다",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 등록을 위한 export
|
||||||
|
export { CardLayoutRenderer } from "./CardLayoutRenderer";
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* card-layout 설정 타입
|
||||||
|
*/
|
||||||
|
export interface Card-layoutConfig {
|
||||||
|
// TODO: 레이아웃 전용 설정 타입 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing?: number;
|
||||||
|
// orientation?: "vertical" | "horizontal";
|
||||||
|
// allowResize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* card-layout Props 타입
|
||||||
|
*/
|
||||||
|
export interface Card-layoutLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // Card-layoutLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* card-layout 존 타입
|
||||||
|
*/
|
||||||
|
export interface Card-layoutZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// TODO: 존별 전용 속성 정의
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flexbox 레이아웃 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface FlexboxLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // FlexboxLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlexboxLayout: React.FC<FlexboxLayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
renderer,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (!layout.layoutConfig.flexbox) {
|
||||||
|
return (
|
||||||
|
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
<div className="font-medium">플렉스박스 레이아웃 설정이 없습니다.</div>
|
||||||
|
<div className="mt-1 text-sm">layoutConfig.flexbox가 필요합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const flexConfig = layout.layoutConfig.flexbox;
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// 플렉스박스 스타일 설정
|
||||||
|
const flexStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: flexConfig.direction,
|
||||||
|
justifyContent: flexConfig.justify,
|
||||||
|
alignItems: flexConfig.align,
|
||||||
|
flexWrap: flexConfig.wrap,
|
||||||
|
gap: `${flexConfig.gap}px`,
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
flexStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
flexStyle.borderRadius = "8px";
|
||||||
|
flexStyle.padding = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flexbox-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
|
||||||
|
style={flexStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={props.onDragStart}
|
||||||
|
onDragEnd={props.onDragEnd}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
// 플렉스 아이템 스타일 설정
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
flex: renderer.calculateFlexValue(zone, flexConfig.direction),
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: zoneStyle,
|
||||||
|
className: "flex-zone",
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 존이 없을 때 안내 메시지 */}
|
||||||
|
{layout.zones.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="empty-flex-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: isDesignMode ? "2px dashed #cbd5e1" : "1px solid #e2e8f0",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.5)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: isDesignMode ? "14px" : "12px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "100px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = "#3b82f6";
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
|
||||||
|
e.currentTarget.style.backgroundColor = isDesignMode
|
||||||
|
? "rgba(148, 163, 184, 0.05)"
|
||||||
|
: "rgba(248, 250, 252, 0.5)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDesignMode ? "플렉스박스 레이아웃에 존을 추가하세요" : "빈 레이아웃"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { FlexboxLayoutDefinition } from "./index";
|
||||||
|
import { FlexboxLayout } from "./FlexboxLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* flexbox 렌더러 (새 구조)
|
||||||
|
*/
|
||||||
|
export class FlexboxLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 (자동 등록용)
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition = FlexboxLayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스 로드 시 자동 등록 실행
|
||||||
|
*/
|
||||||
|
static {
|
||||||
|
this.registerSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 실행
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <FlexboxLayout {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플렉스 아이템의 flex 값을 계산합니다.
|
||||||
|
*/
|
||||||
|
calculateFlexValue(zone: any, direction: string): string {
|
||||||
|
// 존의 크기에 따라 flex 값 결정
|
||||||
|
if (direction === "row" || direction === "row-reverse") {
|
||||||
|
// 가로 방향: width를 기준으로 flex 값 계산
|
||||||
|
if (typeof zone.size.width === "string") {
|
||||||
|
if (zone.size.width.includes("fr")) {
|
||||||
|
return zone.size.width.replace("fr", "");
|
||||||
|
} else if (zone.size.width.includes("%")) {
|
||||||
|
const percent = parseInt(zone.size.width.replace("%", ""));
|
||||||
|
return `0 0 ${percent}%`;
|
||||||
|
} else if (zone.size.width.includes("px")) {
|
||||||
|
return `0 0 ${zone.size.width}`;
|
||||||
|
}
|
||||||
|
} else if (typeof zone.size.width === "number") {
|
||||||
|
return `0 0 ${zone.size.width}px`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 세로 방향: height를 기준으로 flex 값 계산
|
||||||
|
if (typeof zone.size.height === "string") {
|
||||||
|
if (zone.size.height.includes("fr")) {
|
||||||
|
return zone.size.height.replace("fr", "");
|
||||||
|
} else if (zone.size.height.includes("%")) {
|
||||||
|
const percent = parseInt(zone.size.height.replace("%", ""));
|
||||||
|
return `0 0 ${percent}%`;
|
||||||
|
} else if (zone.size.height.includes("px")) {
|
||||||
|
return `0 0 ${zone.size.height}`;
|
||||||
|
}
|
||||||
|
} else if (typeof zone.size.height === "number") {
|
||||||
|
return `0 0 ${zone.size.height}px`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본값: 균등 분할
|
||||||
|
return "1";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React 함수 컴포넌트로 래핑 (외부 사용용)
|
||||||
|
*/
|
||||||
|
export const FlexboxLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new FlexboxLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 모드에서 Hot Reload 지원
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// HMR API 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
(module as any).hot.dispose(() => {
|
||||||
|
FlexboxLayoutRenderer.unregisterSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# flexbox
|
||||||
|
|
||||||
|
유연한 박스 모델을 사용하는 레이아웃입니다.
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `FlexboxLayout.tsx`: 메인 레이아웃 컴포넌트
|
||||||
|
- `FlexboxLayoutRenderer.tsx`: 렌더러 (자동 등록)
|
||||||
|
- `config.ts`: 기본 설정
|
||||||
|
- `types.ts`: 타입 정의
|
||||||
|
- `index.ts`: 진입점
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
1. `FlexboxLayout.tsx`에서 레이아웃 로직 구현
|
||||||
|
2. `config.ts`에서 기본 설정 조정
|
||||||
|
3. `types.ts`에서 타입 정의 추가
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
flexbox: {
|
||||||
|
// TODO: 설정 옵션 문서화
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 존 구성
|
||||||
|
|
||||||
|
- **존 1** (`zone1`): 기본 영역
|
||||||
|
- **존 2** (`zone2`): 기본 영역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
생성일: 2025. 9. 10.
|
||||||
|
버전: 1.0.0
|
||||||
|
작성자: Developer
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* flexbox 기본 설정
|
||||||
|
*/
|
||||||
|
export const FlexboxLayoutConfig = {
|
||||||
|
defaultConfig: {
|
||||||
|
flexbox: {
|
||||||
|
// TODO: 레이아웃 전용 설정 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing: 16,
|
||||||
|
// orientation: "vertical",
|
||||||
|
// allowResize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 설정 스키마 (검증용)
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
flexbox: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
// TODO: 설정 스키마 정의
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["flexbox"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||||||
|
import { FlexboxLayout } from "./FlexboxLayout";
|
||||||
|
import { FlexboxLayoutRenderer } from "./FlexboxLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플렉스박스 레이아웃 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||||||
|
*/
|
||||||
|
const FlexboxLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new FlexboxLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* flexbox 레이아웃 정의
|
||||||
|
*/
|
||||||
|
export const FlexboxLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "flexbox",
|
||||||
|
name: "플렉스박스 레이아웃",
|
||||||
|
nameEng: "Flexbox Layout",
|
||||||
|
description: "유연한 박스 모델을 사용하는 레이아웃입니다.",
|
||||||
|
category: "basic",
|
||||||
|
icon: "flex",
|
||||||
|
component: FlexboxLayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
flexbox: {
|
||||||
|
direction: "row",
|
||||||
|
justify: "flex-start",
|
||||||
|
align: "stretch",
|
||||||
|
wrap: "nowrap",
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "left",
|
||||||
|
name: "좌측 영역",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
name: "우측 영역",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: ["flexbox", "flex", "layout", "basic"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "Screen Management System",
|
||||||
|
documentation: "유연한 박스 모델을 사용하는 레이아웃입니다. 수평/수직 방향과 정렬 방식을 설정할 수 있습니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 등록을 위한 export
|
||||||
|
export { FlexboxLayout } from "./FlexboxLayout";
|
||||||
|
export { FlexboxLayoutRenderer } from "./FlexboxLayoutRenderer";
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* flexbox 설정 타입
|
||||||
|
*/
|
||||||
|
export interface FlexboxConfig {
|
||||||
|
// TODO: 레이아웃 전용 설정 타입 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing?: number;
|
||||||
|
// orientation?: "vertical" | "horizontal";
|
||||||
|
// allowResize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* flexbox Props 타입
|
||||||
|
*/
|
||||||
|
export interface FlexboxLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // FlexboxLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* flexbox 존 타입
|
||||||
|
*/
|
||||||
|
export interface FlexboxZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// TODO: 존별 전용 속성 정의
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 레이아웃 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface GridLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // GridLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GridLayout: React.FC<GridLayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
renderer,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (!layout.layoutConfig.grid) {
|
||||||
|
return (
|
||||||
|
<div className="error-layout flex items-center justify-center rounded border-2 border-red-300 bg-red-50 p-4">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
<div className="font-medium">그리드 레이아웃 설정이 없습니다.</div>
|
||||||
|
<div className="mt-1 text-sm">layoutConfig.grid가 필요합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridConfig = layout.layoutConfig.grid;
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// 그리드 컨테이너 스타일
|
||||||
|
const gridStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateRows: `repeat(${gridConfig.rows}, 1fr)`,
|
||||||
|
gridTemplateColumns: `repeat(${gridConfig.columns}, 1fr)`,
|
||||||
|
gap: `${gridConfig.gap || 16}px`,
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
gridStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
gridStyle.borderRadius = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM props만 추출 (React DOM에서 인식하는 props만)
|
||||||
|
const {
|
||||||
|
children: propsChildren,
|
||||||
|
onUpdateLayout,
|
||||||
|
onSelectComponent,
|
||||||
|
isDesignMode: _isDesignMode,
|
||||||
|
allComponents,
|
||||||
|
onZoneClick,
|
||||||
|
onComponentDrop,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
...domProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`grid-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
|
||||||
|
style={gridStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
{...domProps}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
// 그리드 위치 설정
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
gridRow: zone.position.row !== undefined ? zone.position.row + 1 : undefined,
|
||||||
|
gridColumn: zone.position.column !== undefined ? zone.position.column + 1 : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: zoneStyle,
|
||||||
|
className: "grid-zone",
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 디자인 모드에서 빈 그리드 셀 표시 */}
|
||||||
|
{isDesignMode && <GridEmptyCells gridConfig={gridConfig} layout={layout} isDesignMode={isDesignMode} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 빈 그리드 셀들을 렌더링하는 컴포넌트
|
||||||
|
*/
|
||||||
|
const GridEmptyCells: React.FC<{
|
||||||
|
gridConfig: any;
|
||||||
|
layout: any;
|
||||||
|
isDesignMode: boolean;
|
||||||
|
}> = ({ gridConfig, layout, isDesignMode }) => {
|
||||||
|
const totalCells = gridConfig.rows * gridConfig.columns;
|
||||||
|
const occupiedCells = new Set(
|
||||||
|
layout.zones
|
||||||
|
.map((zone: any) =>
|
||||||
|
zone.position.row !== undefined && zone.position.column !== undefined
|
||||||
|
? zone.position.row * gridConfig.columns + zone.position.column
|
||||||
|
: -1,
|
||||||
|
)
|
||||||
|
.filter((index: number) => index >= 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyCells: React.ReactElement[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < totalCells; i++) {
|
||||||
|
if (!occupiedCells.has(i)) {
|
||||||
|
const row = Math.floor(i / gridConfig.columns);
|
||||||
|
const column = i % gridConfig.columns;
|
||||||
|
|
||||||
|
emptyCells.push(
|
||||||
|
<div
|
||||||
|
key={`empty-${i}`}
|
||||||
|
className="empty-grid-cell"
|
||||||
|
style={{
|
||||||
|
gridRow: row + 1,
|
||||||
|
gridColumn: column + 1,
|
||||||
|
border: isDesignMode ? "1px dashed #cbd5e1" : "1px solid #f1f5f9",
|
||||||
|
borderRadius: "4px",
|
||||||
|
backgroundColor: isDesignMode ? "rgba(148, 163, 184, 0.05)" : "rgba(248, 250, 252, 0.3)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "10px",
|
||||||
|
color: "#94a3b8",
|
||||||
|
minHeight: "40px",
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "rgba(59, 130, 246, 0.05)";
|
||||||
|
e.currentTarget.style.borderColor = "#3b82f6";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = isDesignMode
|
||||||
|
? "rgba(148, 163, 184, 0.05)"
|
||||||
|
: "rgba(248, 250, 252, 0.3)";
|
||||||
|
e.currentTarget.style.borderColor = isDesignMode ? "#cbd5e1" : "#f1f5f9";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDesignMode ? `${row + 1},${column + 1}` : ""}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{emptyCells}</>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { GridLayoutDefinition } from "./index";
|
||||||
|
import { GridLayout } from "./GridLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 레이아웃 렌더러 (새 구조)
|
||||||
|
*/
|
||||||
|
export class GridLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 (자동 등록용)
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition = GridLayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스 로드 시 자동 등록 실행
|
||||||
|
*/
|
||||||
|
static {
|
||||||
|
this.registerSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 실행
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <GridLayout {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React 함수 컴포넌트로 래핑 (외부 사용용)
|
||||||
|
*/
|
||||||
|
export const GridLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new GridLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기존 호환성을 위한 export
|
||||||
|
export { GridLayoutComponent as GridLayout };
|
||||||
|
|
||||||
|
// 개발 모드에서 Hot Reload 지원
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
// HMR API 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
(module as any).hot.dispose(() => {
|
||||||
|
GridLayoutRenderer.unregisterSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||||||
|
import { GridLayout } from "./GridLayout";
|
||||||
|
import { GridLayoutRenderer } from "./GridLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 레이아웃 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||||||
|
*/
|
||||||
|
const GridLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new GridLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 레이아웃 정의
|
||||||
|
*/
|
||||||
|
export const GridLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "grid",
|
||||||
|
name: "그리드 레이아웃",
|
||||||
|
nameEng: "Grid Layout",
|
||||||
|
description: "행과 열로 구성된 격자 형태의 레이아웃입니다. 정확한 위치 제어가 가능합니다.",
|
||||||
|
category: "basic",
|
||||||
|
icon: "grid",
|
||||||
|
component: GridLayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
grid: {
|
||||||
|
rows: 2,
|
||||||
|
columns: 2,
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: { row: 0, column: 0 },
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: { row: 0, column: 1 },
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "존 3",
|
||||||
|
position: { row: 1, column: 0 },
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone4",
|
||||||
|
name: "존 4",
|
||||||
|
position: { row: 1, column: 1 },
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: ["grid", "layout", "basic", "structured"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "Screen Management System",
|
||||||
|
documentation: "2x2 그리드로 구성된 기본 레이아웃입니다. 각 존은 정확한 그리드 위치에 배치됩니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 등록을 위한 export
|
||||||
|
export { GridLayout } from "./GridLayout";
|
||||||
|
export { GridLayoutRenderer } from "./GridLayoutRenderer";
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heroSection 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface HeroSectionLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // HeroSectionLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HeroSectionLayout: React.FC<HeroSectionLayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
renderer,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (!layout.layoutConfig.heroSection) {
|
||||||
|
return (
|
||||||
|
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
<div className="font-medium">heroSection 설정이 없습니다.</div>
|
||||||
|
<div className="text-sm mt-1">layoutConfig.heroSection가 필요합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const heroSectionConfig = layout.layoutConfig.heroSection;
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// heroSection 컨테이너 스타일
|
||||||
|
const heroSectionStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
// TODO: 레이아웃 전용 스타일 정의
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
heroSectionStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
heroSectionStyle.borderRadius = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`hero-section-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
|
||||||
|
style={heroSectionStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={props.onDragStart}
|
||||||
|
onDragEnd={props.onDragEnd}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
// TODO: 존별 스타일 정의
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
// 레이아웃별 존 스타일 구현
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: zoneStyle,
|
||||||
|
className: "hero-section-zone",
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 디자인 모드에서 빈 영역 표시 */}
|
||||||
|
{isDesignMode && layout.zones.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="empty-hero-section-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "2px dashed #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "rgba(148, 163, 184, 0.05)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "100px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
heroSection에 존을 추가하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { HeroSectionLayoutDefinition } from "./index";
|
||||||
|
import { HeroSectionLayout } from "./HeroSectionLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heroSection 렌더러 (새 구조)
|
||||||
|
*/
|
||||||
|
export class HeroSectionLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 (자동 등록용)
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition = HeroSectionLayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스 로드 시 자동 등록 실행
|
||||||
|
*/
|
||||||
|
static {
|
||||||
|
this.registerSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 실행
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <HeroSectionLayout {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React 함수 컴포넌트로 래핑 (외부 사용용)
|
||||||
|
*/
|
||||||
|
export const HeroSectionLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new HeroSectionLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 모드에서 Hot Reload 지원
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// HMR API 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
(module as any).hot.dispose(() => {
|
||||||
|
HeroSectionLayoutRenderer.unregisterSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
# heroSection
|
||||||
|
|
||||||
|
영웅 섹션 레이아웃입니다
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `HeroSectionLayout.tsx`: 메인 레이아웃 컴포넌트
|
||||||
|
- `HeroSectionLayoutRenderer.tsx`: 렌더러 (자동 등록)
|
||||||
|
- `config.ts`: 기본 설정
|
||||||
|
- `types.ts`: 타입 정의
|
||||||
|
- `index.ts`: 진입점
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
1. `HeroSectionLayout.tsx`에서 레이아웃 로직 구현
|
||||||
|
2. `config.ts`에서 기본 설정 조정
|
||||||
|
3. `types.ts`에서 타입 정의 추가
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
hero-section: {
|
||||||
|
// TODO: 설정 옵션 문서화
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 존 구성
|
||||||
|
|
||||||
|
- **존 1** (`zone1`): 기본 영역
|
||||||
|
- **존 2** (`zone2`): 기본 영역
|
||||||
|
- **존 3** (`zone3`): 기본 영역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
생성일: 2025. 9. 10.
|
||||||
|
버전: 1.0.0
|
||||||
|
작성자: 개발자
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* heroSection 기본 설정
|
||||||
|
*/
|
||||||
|
export const HeroSectionLayoutConfig = {
|
||||||
|
defaultConfig: {
|
||||||
|
hero-section: {
|
||||||
|
// TODO: 레이아웃 전용 설정 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing: 16,
|
||||||
|
// orientation: "vertical",
|
||||||
|
// allowResize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "존 3",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 설정 스키마 (검증용)
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
hero-section: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
// TODO: 설정 스키마 정의
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["hero-section"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||||||
|
import { HeroSectionLayout } from "./HeroSectionLayout";
|
||||||
|
import { HeroSectionLayoutRenderer } from "./HeroSectionLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heroSection 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||||||
|
*/
|
||||||
|
const HeroSectionLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new HeroSectionLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heroSection 레이아웃 정의
|
||||||
|
*/
|
||||||
|
export const HeroSectionLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "hero-section",
|
||||||
|
name: "heroSection",
|
||||||
|
nameEng: "HeroSection Layout",
|
||||||
|
description: "영웅 섹션 레이아웃입니다",
|
||||||
|
category: "content",
|
||||||
|
icon: "hero-section",
|
||||||
|
component: HeroSectionLayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
heroSection: {
|
||||||
|
// TODO: 레이아웃별 설정 정의
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "존 3",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tags: ["hero-section", "content", "layout"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "개발자",
|
||||||
|
documentation: "영웅 섹션 레이아웃입니다",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 등록을 위한 export
|
||||||
|
export { HeroSectionLayoutRenderer } from "./HeroSectionLayoutRenderer";
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heroSection 설정 타입
|
||||||
|
*/
|
||||||
|
export interface HeroSectionConfig {
|
||||||
|
// TODO: 레이아웃 전용 설정 타입 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing?: number;
|
||||||
|
// orientation?: "vertical" | "horizontal";
|
||||||
|
// allowResize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heroSection Props 타입
|
||||||
|
*/
|
||||||
|
export interface HeroSectionLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // HeroSectionLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* heroSection 존 타입
|
||||||
|
*/
|
||||||
|
export interface HeroSectionZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// TODO: 존별 전용 속성 정의
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LayoutRegistry } from "../LayoutRegistry";
|
||||||
|
import { discoverLayouts } from "../utils/autoDiscovery";
|
||||||
|
|
||||||
|
// 기존 레이아웃들 (호환성을 위해 유지)
|
||||||
|
import { SplitLayout } from "./SplitLayoutRenderer";
|
||||||
|
import { TabsLayout } from "./TabsLayoutRenderer";
|
||||||
|
|
||||||
|
// 새 구조 레이아웃들 (자동 등록)
|
||||||
|
import "./grid/GridLayoutRenderer";
|
||||||
|
import "./flexbox/FlexboxLayoutRenderer";
|
||||||
|
import "./accordion/AccordionLayoutRenderer";
|
||||||
|
import "./split/SplitLayoutRenderer";
|
||||||
|
import "./card-layout/CardLayoutRenderer";
|
||||||
|
import "./hero-section/HeroSectionLayoutRenderer";
|
||||||
|
|
||||||
|
// 레이아웃 초기화 함수 (새 구조 + 기존 구조 하이브리드)
|
||||||
|
export async function initializeLayouts() {
|
||||||
|
console.log("🚀 레이아웃 시스템 초기화 시작...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 자동 디스커버리 실행 (새 구조)
|
||||||
|
const discoveryResult = await discoverLayouts({
|
||||||
|
pattern: "./**/*LayoutRenderer.tsx",
|
||||||
|
verbose: true,
|
||||||
|
continueOnError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ 자동 디스커버리 완료: ${discoveryResult.successfullyLoaded}개 레이아웃 로드`);
|
||||||
|
|
||||||
|
// 2. 기존 구조 레이아웃들 수동 등록 (마이그레이션 완료 시까지)
|
||||||
|
await initializeLegacyLayouts();
|
||||||
|
|
||||||
|
const totalLayouts = LayoutRegistry.getAllLayouts().length;
|
||||||
|
console.log(`🎉 레이아웃 시스템 초기화 완료: 총 ${totalLayouts}개 레이아웃 등록`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
autoDiscovered: discoveryResult.successfullyLoaded,
|
||||||
|
legacy: totalLayouts - discoveryResult.successfullyLoaded,
|
||||||
|
total: totalLayouts,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 레이아웃 시스템 초기화 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 구조 레이아웃들 등록 (임시)
|
||||||
|
async function initializeLegacyLayouts() {
|
||||||
|
console.log("🔄 기존 구조 레이아웃 로드 중...");
|
||||||
|
|
||||||
|
// 플렉스박스 레이아웃 (새 구조로 마이그레이션됨 - 스킵)
|
||||||
|
|
||||||
|
// 분할 레이아웃 (새 구조로 마이그레이션됨 - 스킵)
|
||||||
|
|
||||||
|
// 탭 레이아웃 (기존 구조 - 임시)
|
||||||
|
LayoutRegistry.registerLayout({
|
||||||
|
id: "tabs",
|
||||||
|
name: "탭 레이아웃",
|
||||||
|
nameEng: "Tabs Layout",
|
||||||
|
description: "탭으로 구성된 다중 패널 레이아웃입니다.",
|
||||||
|
category: "navigation",
|
||||||
|
icon: "tabs",
|
||||||
|
component: TabsLayout,
|
||||||
|
defaultConfig: {
|
||||||
|
tabs: {
|
||||||
|
position: "top",
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
closable: false,
|
||||||
|
defaultTab: "tab1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "tab1",
|
||||||
|
name: "탭 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab2",
|
||||||
|
name: "탭 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab3",
|
||||||
|
name: "탭 3",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: ["tabs", "navigation", "multi-panel"],
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ 기존 구조 레이아웃 로드 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레지스트리에서 레이아웃 조회하는 헬퍼 함수들
|
||||||
|
export const getLayoutComponent = (layoutType: string) => LayoutRegistry.getLayout(layoutType)?.component;
|
||||||
|
|
||||||
|
export const getAllLayoutDefinitions = () => LayoutRegistry.getAllLayouts();
|
||||||
|
|
||||||
|
// 앱 시작 시 자동 실행
|
||||||
|
console.log("📦 레이아웃 모듈 로드됨");
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
# split
|
||||||
|
|
||||||
|
크기 조절이 가능한 분할된 영역의 레이아웃입니다.
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- `SplitLayout.tsx`: 메인 레이아웃 컴포넌트
|
||||||
|
- `SplitLayoutRenderer.tsx`: 렌더러 (자동 등록)
|
||||||
|
- `config.ts`: 기본 설정
|
||||||
|
- `types.ts`: 타입 정의
|
||||||
|
- `index.ts`: 진입점
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
1. `SplitLayout.tsx`에서 레이아웃 로직 구현
|
||||||
|
2. `config.ts`에서 기본 설정 조정
|
||||||
|
3. `types.ts`에서 타입 정의 추가
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
split: {
|
||||||
|
// TODO: 설정 옵션 문서화
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 존 구성
|
||||||
|
|
||||||
|
- **존 1** (`zone1`): 기본 영역
|
||||||
|
- **존 2** (`zone2`): 기본 영역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
생성일: 2025. 9. 10.
|
||||||
|
버전: 1.0.0
|
||||||
|
작성자: Developer
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* split 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface SplitLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // SplitLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SplitLayout: React.FC<SplitLayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
renderer,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (!layout.layoutConfig.split) {
|
||||||
|
return (
|
||||||
|
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
<div className="font-medium">split 설정이 없습니다.</div>
|
||||||
|
<div className="text-sm mt-1">layoutConfig.split가 필요합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitConfig = layout.layoutConfig.split;
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// split 컨테이너 스타일
|
||||||
|
const splitStyle: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
// TODO: 레이아웃 전용 스타일 정의
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
splitStyle.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
splitStyle.borderRadius = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`split-layout ${isDesignMode ? "design-mode" : ""} ${className}`}
|
||||||
|
style={splitStyle}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={props.onDragStart}
|
||||||
|
onDragEnd={props.onDragEnd}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
// TODO: 존별 스타일 정의
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
// 레이아웃별 존 스타일 구현
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: zoneStyle,
|
||||||
|
className: "split-zone",
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 디자인 모드에서 빈 영역 표시 */}
|
||||||
|
{isDesignMode && layout.zones.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="empty-split-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "2px dashed #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "rgba(148, 163, 184, 0.05)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "100px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
split에 존을 추가하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { SplitLayoutDefinition } from "./index";
|
||||||
|
import { SplitLayout } from "./SplitLayout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* split 렌더러 (새 구조)
|
||||||
|
*/
|
||||||
|
export class SplitLayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 (자동 등록용)
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition = SplitLayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스 로드 시 자동 등록 실행
|
||||||
|
*/
|
||||||
|
static {
|
||||||
|
this.registerSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 실행
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <SplitLayout {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React 함수 컴포넌트로 래핑 (외부 사용용)
|
||||||
|
*/
|
||||||
|
export const SplitLayoutComponent: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new SplitLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 모드에서 Hot Reload 지원
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// HMR API 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
(module as any).hot.dispose(() => {
|
||||||
|
SplitLayoutRenderer.unregisterSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* split 기본 설정
|
||||||
|
*/
|
||||||
|
export const SplitLayoutConfig = {
|
||||||
|
defaultConfig: {
|
||||||
|
split: {
|
||||||
|
// TODO: 레이아웃 전용 설정 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing: 16,
|
||||||
|
// orientation: "vertical",
|
||||||
|
// allowResize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "존 1",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "존 2",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 설정 스키마 (검증용)
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
split: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
// TODO: 설정 스키마 정의
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["split"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||||||
|
import { SplitLayout } from "./SplitLayout";
|
||||||
|
import { SplitLayoutRenderer } from "./SplitLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 분할 레이아웃 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||||||
|
*/
|
||||||
|
const SplitLayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new SplitLayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* split 레이아웃 정의
|
||||||
|
*/
|
||||||
|
export const SplitLayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "split",
|
||||||
|
name: "분할 레이아웃",
|
||||||
|
nameEng: "Split Layout",
|
||||||
|
description: "크기 조절이 가능한 분할된 영역의 레이아웃입니다.",
|
||||||
|
category: "basic",
|
||||||
|
icon: "split",
|
||||||
|
component: SplitLayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
split: {
|
||||||
|
direction: "horizontal",
|
||||||
|
ratio: [50, 50],
|
||||||
|
minSize: [200, 200],
|
||||||
|
resizable: true,
|
||||||
|
splitterSize: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [
|
||||||
|
{
|
||||||
|
id: "left",
|
||||||
|
name: "좌측 패널",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
isResizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
name: "우측 패널",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
isResizable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: ["split", "basic", "layout"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "Developer",
|
||||||
|
documentation: "크기 조절이 가능한 분할된 영역의 레이아웃입니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 등록을 위한 export
|
||||||
|
export { SplitLayout } from "./SplitLayout";
|
||||||
|
export { SplitLayoutRenderer } from "./SplitLayoutRenderer";
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* split 설정 타입
|
||||||
|
*/
|
||||||
|
export interface SplitConfig {
|
||||||
|
// TODO: 레이아웃 전용 설정 타입 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing?: number;
|
||||||
|
// orientation?: "vertical" | "horizontal";
|
||||||
|
// allowResize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* split Props 타입
|
||||||
|
*/
|
||||||
|
export interface SplitLayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // SplitLayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* split 존 타입
|
||||||
|
*/
|
||||||
|
export interface SplitZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// TODO: 존별 전용 속성 정의
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LayoutRegistry } from "../LayoutRegistry";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 디스커버리 옵션
|
||||||
|
*/
|
||||||
|
export interface AutoDiscoveryOptions {
|
||||||
|
/** 스캔할 디렉토리 패턴 */
|
||||||
|
pattern?: string;
|
||||||
|
/** 개발 모드에서 상세 로그 출력 */
|
||||||
|
verbose?: boolean;
|
||||||
|
/** 에러 시 계속 진행할지 여부 */
|
||||||
|
continueOnError?: boolean;
|
||||||
|
/** 최대 대기 시간 (ms) */
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 모듈 정보
|
||||||
|
*/
|
||||||
|
export interface LayoutModuleInfo {
|
||||||
|
path: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
loaded: boolean;
|
||||||
|
error?: Error;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 디스커버리 결과
|
||||||
|
*/
|
||||||
|
export interface DiscoveryResult {
|
||||||
|
success: boolean;
|
||||||
|
totalFound: number;
|
||||||
|
successfullyLoaded: number;
|
||||||
|
failed: number;
|
||||||
|
modules: LayoutModuleInfo[];
|
||||||
|
errors: Error[];
|
||||||
|
duration: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 자동 디스커버리 클래스
|
||||||
|
*/
|
||||||
|
export class LayoutAutoDiscovery {
|
||||||
|
private static instance: LayoutAutoDiscovery;
|
||||||
|
private discoveryResults: DiscoveryResult[] = [];
|
||||||
|
private isDiscovering = false;
|
||||||
|
|
||||||
|
static getInstance(): LayoutAutoDiscovery {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new LayoutAutoDiscovery();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 자동 디스커버리 실행
|
||||||
|
*/
|
||||||
|
async discover(options: AutoDiscoveryOptions = {}): Promise<DiscoveryResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const {
|
||||||
|
pattern = "./**/*LayoutRenderer.tsx",
|
||||||
|
verbose = process.env.NODE_ENV === "development",
|
||||||
|
continueOnError = true,
|
||||||
|
timeout = 10000,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
if (this.isDiscovering) {
|
||||||
|
throw new Error("디스커버리가 이미 진행 중입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDiscovering = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (verbose) {
|
||||||
|
console.log("🔍 레이아웃 자동 디스커버리 시작...");
|
||||||
|
console.log(`📁 스캔 패턴: ${pattern}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules: LayoutModuleInfo[] = [];
|
||||||
|
const errors: Error[] = [];
|
||||||
|
|
||||||
|
// 현재는 정적 import를 사용 (Vite/Next.js 환경에서)
|
||||||
|
const layoutModules = await this.discoverLayoutModules(pattern);
|
||||||
|
|
||||||
|
for (const [path, moduleFactory] of Object.entries(layoutModules)) {
|
||||||
|
const moduleInfo: LayoutModuleInfo = {
|
||||||
|
path,
|
||||||
|
id: this.extractLayoutId(path),
|
||||||
|
name: this.extractLayoutName(path),
|
||||||
|
loaded: false,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 모듈 로드 시도
|
||||||
|
await this.loadLayoutModule(moduleFactory, moduleInfo);
|
||||||
|
moduleInfo.loaded = true;
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.log(`✅ 로드 성공: ${moduleInfo.name} (${moduleInfo.id})`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error;
|
||||||
|
moduleInfo.error = err;
|
||||||
|
errors.push(err);
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
console.error(`❌ 로드 실패: ${moduleInfo.name}`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!continueOnError) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules.push(moduleInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: DiscoveryResult = {
|
||||||
|
success: errors.length === 0,
|
||||||
|
totalFound: modules.length,
|
||||||
|
successfullyLoaded: modules.filter((m) => m.loaded).length,
|
||||||
|
failed: errors.length,
|
||||||
|
modules,
|
||||||
|
errors,
|
||||||
|
duration: Date.now() - startTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.discoveryResults.push(result);
|
||||||
|
|
||||||
|
if (verbose) {
|
||||||
|
this.logDiscoveryResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
this.isDiscovering = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 모듈 발견
|
||||||
|
*/
|
||||||
|
private async discoverLayoutModules(pattern: string): Promise<Record<string, () => Promise<any>>> {
|
||||||
|
try {
|
||||||
|
// Vite의 import.meta.glob 사용
|
||||||
|
if (typeof import.meta !== "undefined" && import.meta.glob) {
|
||||||
|
return import.meta.glob(pattern, { eager: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next.js의 경우 또는 fallback
|
||||||
|
return await this.discoverModulesViaWebpack(pattern);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("자동 디스커버리 실패, 수동 import로 전환:", error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webpack 기반 모듈 디스커버리 (Next.js용)
|
||||||
|
*/
|
||||||
|
private async discoverModulesViaWebpack(pattern: string): Promise<Record<string, () => Promise<any>>> {
|
||||||
|
// Next.js 환경에서는 require.context 사용
|
||||||
|
if (typeof require !== "undefined" && require.context) {
|
||||||
|
const context = require.context("../layouts", true, /.*LayoutRenderer\.tsx$/);
|
||||||
|
const modules: Record<string, () => Promise<any>> = {};
|
||||||
|
|
||||||
|
context.keys().forEach((key: string) => {
|
||||||
|
modules[key] = () => Promise.resolve(context(key));
|
||||||
|
});
|
||||||
|
|
||||||
|
return modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 모듈 로드
|
||||||
|
*/
|
||||||
|
private async loadLayoutModule(moduleFactory: () => Promise<any>, moduleInfo: LayoutModuleInfo): Promise<void> {
|
||||||
|
const module = await moduleFactory();
|
||||||
|
|
||||||
|
// default export가 있는 경우
|
||||||
|
if (module.default && typeof module.default.registerSelf === "function") {
|
||||||
|
module.default.registerSelf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// named export 중에 레이아웃 렌더러가 있는 경우
|
||||||
|
for (const [exportName, exportValue] of Object.entries(module)) {
|
||||||
|
if (exportValue && typeof (exportValue as any).registerSelf === "function") {
|
||||||
|
(exportValue as any).registerSelf();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`레이아웃 렌더러를 찾을 수 없습니다: ${moduleInfo.path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경로에서 레이아웃 ID 추출
|
||||||
|
*/
|
||||||
|
private extractLayoutId(path: string): string {
|
||||||
|
const match = path.match(/\/([^/]+)LayoutRenderer\.tsx$/);
|
||||||
|
return match ? match[1].toLowerCase() : "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 경로에서 레이아웃 이름 추출
|
||||||
|
*/
|
||||||
|
private extractLayoutName(path: string): string {
|
||||||
|
const id = this.extractLayoutId(path);
|
||||||
|
return id.charAt(0).toUpperCase() + id.slice(1) + " Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디스커버리 결과 로그 출력
|
||||||
|
*/
|
||||||
|
private logDiscoveryResult(result: DiscoveryResult): void {
|
||||||
|
console.group("📊 레이아웃 디스커버리 결과");
|
||||||
|
console.log(`⏱️ 소요 시간: ${result.duration}ms`);
|
||||||
|
console.log(`📦 발견된 모듈: ${result.totalFound}개`);
|
||||||
|
console.log(`✅ 성공적으로 로드: ${result.successfullyLoaded}개`);
|
||||||
|
console.log(`❌ 실패: ${result.failed}개`);
|
||||||
|
|
||||||
|
if (result.modules.length > 0) {
|
||||||
|
console.table(
|
||||||
|
result.modules.map((m) => ({
|
||||||
|
ID: m.id,
|
||||||
|
Name: m.name,
|
||||||
|
Loaded: m.loaded ? "✅" : "❌",
|
||||||
|
Path: m.path,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
console.group("❌ 오류 상세:");
|
||||||
|
result.errors.forEach((error) => console.error(error));
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 디스커버리 결과 조회
|
||||||
|
*/
|
||||||
|
getDiscoveryHistory(): DiscoveryResult[] {
|
||||||
|
return [...this.discoveryResults];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 정보 조회
|
||||||
|
*/
|
||||||
|
getStats(): { totalAttempts: number; successRate: number; avgDuration: number } {
|
||||||
|
const attempts = this.discoveryResults.length;
|
||||||
|
const successful = this.discoveryResults.filter((r) => r.success).length;
|
||||||
|
const avgDuration = attempts > 0 ? this.discoveryResults.reduce((sum, r) => sum + r.duration, 0) / attempts : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAttempts: attempts,
|
||||||
|
successRate: attempts > 0 ? (successful / attempts) * 100 : 0,
|
||||||
|
avgDuration: Math.round(avgDuration),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 편의 함수: 레이아웃 자동 디스커버리 실행
|
||||||
|
*/
|
||||||
|
export async function discoverLayouts(options?: AutoDiscoveryOptions): Promise<DiscoveryResult> {
|
||||||
|
const discovery = LayoutAutoDiscovery.getInstance();
|
||||||
|
return discovery.discover(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 편의 함수: 통계 정보 조회
|
||||||
|
*/
|
||||||
|
export function getDiscoveryStats() {
|
||||||
|
const discovery = LayoutAutoDiscovery.getInstance();
|
||||||
|
return discovery.getStats();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LayoutDefinition, LayoutType, LayoutCategory } from "@/types/layout";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의를 생성하는 헬퍼 함수
|
||||||
|
* 타입 안전성과 기본값을 보장합니다.
|
||||||
|
*/
|
||||||
|
export interface CreateLayoutDefinitionOptions {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameEng?: string;
|
||||||
|
description: string;
|
||||||
|
category: LayoutCategory;
|
||||||
|
icon?: string;
|
||||||
|
component: React.ComponentType<any>;
|
||||||
|
defaultConfig?: Record<string, any>;
|
||||||
|
defaultZones?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position?: Record<string, any>;
|
||||||
|
size?: {
|
||||||
|
width: number | string;
|
||||||
|
height: number | string;
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
};
|
||||||
|
[key: string]: any;
|
||||||
|
}>;
|
||||||
|
tags?: string[];
|
||||||
|
isActive?: boolean;
|
||||||
|
version?: string;
|
||||||
|
author?: string;
|
||||||
|
documentation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의를 생성합니다.
|
||||||
|
*/
|
||||||
|
export function createLayoutDefinition(options: CreateLayoutDefinitionOptions): LayoutDefinition {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
nameEng = name,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
icon = "layout",
|
||||||
|
component,
|
||||||
|
defaultConfig = {},
|
||||||
|
defaultZones = [],
|
||||||
|
tags = [],
|
||||||
|
isActive = true,
|
||||||
|
version = "1.0.0",
|
||||||
|
author = "Developer",
|
||||||
|
documentation = "",
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// ID 유효성 검사
|
||||||
|
if (!id || typeof id !== "string" || !/^[a-z][a-z0-9-]*$/.test(id)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid layout ID: "${id}". ID must start with a letter and contain only lowercase letters, numbers, and hyphens.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필수 필드 검사
|
||||||
|
if (!name.trim()) {
|
||||||
|
throw new Error("Layout name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description.trim()) {
|
||||||
|
throw new Error("Layout description is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!component) {
|
||||||
|
throw new Error("Layout component is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 태그 자동 추가
|
||||||
|
const autoTags = [id, category, "layout"];
|
||||||
|
const allTags = [...new Set([...autoTags, ...tags])];
|
||||||
|
|
||||||
|
const definition: LayoutDefinition = {
|
||||||
|
id: id as LayoutType,
|
||||||
|
name,
|
||||||
|
nameEng,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
icon,
|
||||||
|
component,
|
||||||
|
defaultConfig,
|
||||||
|
defaultZones,
|
||||||
|
tags: allTags,
|
||||||
|
isActive,
|
||||||
|
metadata: {
|
||||||
|
version,
|
||||||
|
author,
|
||||||
|
documentation,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📦 레이아웃 정의 생성: ${id} (${name})`);
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의의 유효성을 검사합니다.
|
||||||
|
*/
|
||||||
|
export function validateLayoutDefinition(definition: LayoutDefinition): {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
} {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// 필수 필드 검사
|
||||||
|
if (!definition.id) errors.push("ID is required");
|
||||||
|
if (!definition.name) errors.push("Name is required");
|
||||||
|
if (!definition.description) errors.push("Description is required");
|
||||||
|
if (!definition.category) errors.push("Category is required");
|
||||||
|
if (!definition.component) errors.push("Component is required");
|
||||||
|
|
||||||
|
// ID 형식 검사
|
||||||
|
if (definition.id && !/^[a-z][a-z0-9-]*$/.test(definition.id)) {
|
||||||
|
errors.push("ID must start with a letter and contain only lowercase letters, numbers, and hyphens");
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultZones 검사
|
||||||
|
if (definition.defaultZones) {
|
||||||
|
definition.defaultZones.forEach((zone, index) => {
|
||||||
|
if (!zone.id) errors.push(`Zone ${index}: ID is required`);
|
||||||
|
if (!zone.name) errors.push(`Zone ${index}: Name is required`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 경고사항 검사
|
||||||
|
if (!definition.nameEng) warnings.push("English name is recommended");
|
||||||
|
if (!definition.icon || definition.icon === "layout") warnings.push("Custom icon is recommended");
|
||||||
|
if (!definition.defaultZones || definition.defaultZones.length === 0) {
|
||||||
|
warnings.push("Default zones are recommended for better UX");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의를 디버그 정보와 함께 출력합니다.
|
||||||
|
*/
|
||||||
|
export function debugLayoutDefinition(definition: LayoutDefinition): void {
|
||||||
|
console.group(`🔍 Layout Debug: ${definition.id}`);
|
||||||
|
console.log("📋 Definition:", definition);
|
||||||
|
|
||||||
|
const validation = validateLayoutDefinition(definition);
|
||||||
|
if (validation.errors.length > 0) {
|
||||||
|
console.error("❌ Errors:", validation.errors);
|
||||||
|
}
|
||||||
|
if (validation.warnings.length > 0) {
|
||||||
|
console.warn("⚠️ Warnings:", validation.warnings);
|
||||||
|
}
|
||||||
|
if (validation.isValid) {
|
||||||
|
console.log("✅ Definition is valid");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"lint:fix": "next lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check .",
|
||||||
|
"create-layout": "node scripts/create-layout.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,524 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { execSync } = require("child_process");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 스캐폴딩 CLI 도구
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* npm run create-layout <layoutName> [options]
|
||||||
|
*
|
||||||
|
* 예시:
|
||||||
|
* npm run create-layout accordion
|
||||||
|
* npm run create-layout sidebar --category=navigation --zones=3
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 명령행 인자 파싱
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const layoutName = args[0];
|
||||||
|
|
||||||
|
if (!layoutName) {
|
||||||
|
console.error("❌ 레이아웃 이름이 필요합니다.");
|
||||||
|
console.log("사용법: npm run create-layout <layoutName> [options]");
|
||||||
|
console.log("예시: npm run create-layout accordion --category=navigation");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 옵션 파싱
|
||||||
|
const options = {};
|
||||||
|
args.slice(1).forEach((arg) => {
|
||||||
|
if (arg.startsWith("--")) {
|
||||||
|
const [key, value] = arg.slice(2).split("=");
|
||||||
|
options[key] = value || true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하이픈을 카멜케이스로 변환
|
||||||
|
*/
|
||||||
|
function toCamelCase(str) {
|
||||||
|
return str.replace(/-([a-z])/g, (match, letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하이픈을 파스칼케이스로 변환
|
||||||
|
*/
|
||||||
|
function toPascalCase(str) {
|
||||||
|
const camelCase = toCamelCase(str);
|
||||||
|
return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 안전한 식별자명 생성 (하이픈 제거)
|
||||||
|
*/
|
||||||
|
function toSafeId(str) {
|
||||||
|
return str.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 안전한 이름들 생성
|
||||||
|
const safeLayoutName = toCamelCase(layoutName);
|
||||||
|
const pascalLayoutName = toPascalCase(layoutName);
|
||||||
|
const safeId = layoutName.toLowerCase(); // kebab-case는 id로 유지
|
||||||
|
|
||||||
|
// 기본 옵션
|
||||||
|
const config = {
|
||||||
|
name: safeLayoutName,
|
||||||
|
id: safeId,
|
||||||
|
className: pascalLayoutName,
|
||||||
|
category: options.category || "basic",
|
||||||
|
zones: parseInt(options.zones) || 2,
|
||||||
|
description: options.description || `${safeLayoutName} 레이아웃입니다.`,
|
||||||
|
author: options.author || "Developer",
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검증
|
||||||
|
if (!/^[a-z][a-z0-9-]*$/.test(config.id)) {
|
||||||
|
console.error("❌ 레이아웃 이름은 소문자로 시작하고 소문자, 숫자, 하이픈만 포함해야 합니다.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutDir = path.join(__dirname, "../lib/registry/layouts", config.id);
|
||||||
|
|
||||||
|
// 디렉토리 존재 확인
|
||||||
|
if (fs.existsSync(layoutDir)) {
|
||||||
|
console.error(`❌ 레이아웃 디렉토리가 이미 존재합니다: ${layoutDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🚀 새 레이아웃 생성 중...");
|
||||||
|
console.log(`📁 이름: ${config.name}`);
|
||||||
|
console.log(`🔖 ID: ${config.id}`);
|
||||||
|
console.log(`📂 카테고리: ${config.category}`);
|
||||||
|
console.log(`🎯 존 개수: ${config.zones}`);
|
||||||
|
|
||||||
|
// 디렉토리 생성
|
||||||
|
fs.mkdirSync(layoutDir, { recursive: true });
|
||||||
|
|
||||||
|
// 템플릿 파일들 생성
|
||||||
|
createIndexFile();
|
||||||
|
createLayoutComponent();
|
||||||
|
createLayoutRenderer();
|
||||||
|
createConfigFile();
|
||||||
|
createTypesFile();
|
||||||
|
createReadme();
|
||||||
|
|
||||||
|
// package.json 스크립트 업데이트
|
||||||
|
updatePackageScripts();
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("✅ 레이아웃 생성 완료!");
|
||||||
|
console.log("");
|
||||||
|
console.log("📝 다음 단계:");
|
||||||
|
console.log(`1. ${layoutDir}/${config.className}Layout.tsx 에서 비즈니스 로직 구현`);
|
||||||
|
console.log("2. 파일을 저장하면 자동으로 화면편집기에서 사용 가능");
|
||||||
|
console.log("3. 필요에 따라 config.ts에서 기본 설정 조정");
|
||||||
|
console.log("");
|
||||||
|
console.log("🔧 개발 팁:");
|
||||||
|
console.log("- 브라우저 개발자 도구에서 window.__LAYOUT_REGISTRY__.list() 로 등록 확인");
|
||||||
|
console.log("- Hot Reload 지원으로 파일 수정 시 자동 업데이트");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* index.ts 파일 생성
|
||||||
|
*/
|
||||||
|
function createIndexFile() {
|
||||||
|
const content = `"use client";
|
||||||
|
|
||||||
|
import { createLayoutDefinition } from "../../utils/createLayoutDefinition";
|
||||||
|
import { ${config.className}Layout } from "./${config.className}Layout";
|
||||||
|
import { ${config.className}LayoutRenderer } from "./${config.className}LayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${config.name} 래퍼 컴포넌트 (DynamicLayoutRenderer용)
|
||||||
|
*/
|
||||||
|
const ${config.className}LayoutWrapper: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new ${config.className}LayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${config.name} 레이아웃 정의
|
||||||
|
*/
|
||||||
|
export const ${config.className}LayoutDefinition = createLayoutDefinition({
|
||||||
|
id: "${config.id}",
|
||||||
|
name: "${config.name}",
|
||||||
|
nameEng: "${config.className} Layout",
|
||||||
|
description: "${config.description}",
|
||||||
|
category: "${config.category}",
|
||||||
|
icon: "${config.icon || config.id}",
|
||||||
|
component: ${config.className}LayoutWrapper,
|
||||||
|
defaultConfig: {
|
||||||
|
${toCamelCase(config.id)}: {
|
||||||
|
// TODO: 레이아웃별 설정 정의
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultZones: [${generateDefaultZones()}
|
||||||
|
],
|
||||||
|
tags: ["${config.id}", "${config.category}", "layout"],
|
||||||
|
version: "1.0.0",
|
||||||
|
author: "${config.author}",
|
||||||
|
documentation: "${config.description}",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동 등록을 위한 export
|
||||||
|
export { ${config.className}LayoutRenderer } from "./${config.className}LayoutRenderer";
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(layoutDir, "index.ts"), content);
|
||||||
|
console.log("✅ index.ts 생성됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 컴포넌트 파일 생성
|
||||||
|
*/
|
||||||
|
function createLayoutComponent() {
|
||||||
|
const content = `"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${config.name} 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface ${config.className}LayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // ${config.className}LayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ${config.className}Layout: React.FC<${config.className}LayoutProps> = ({
|
||||||
|
layout,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
onClick,
|
||||||
|
className = "",
|
||||||
|
renderer,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
if (!layout.layoutConfig.${toCamelCase(config.id)}) {
|
||||||
|
return (
|
||||||
|
<div className="error-layout flex items-center justify-center p-4 border-2 border-red-300 bg-red-50 rounded">
|
||||||
|
<div className="text-center text-red-600">
|
||||||
|
<div className="font-medium">${config.name} 설정이 없습니다.</div>
|
||||||
|
<div className="text-sm mt-1">layoutConfig.${toCamelCase(config.id)}가 필요합니다.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ${config.name}Config = layout.layoutConfig.${toCamelCase(config.id)};
|
||||||
|
const containerStyle = renderer.getLayoutContainerStyle();
|
||||||
|
|
||||||
|
// ${config.name} 컨테이너 스타일
|
||||||
|
const ${config.name}Style: React.CSSProperties = {
|
||||||
|
...containerStyle,
|
||||||
|
// TODO: 레이아웃 전용 스타일 정의
|
||||||
|
height: "100%",
|
||||||
|
width: "100%",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 디자인 모드 스타일
|
||||||
|
if (isDesignMode) {
|
||||||
|
${config.name}Style.border = isSelected ? "2px solid #3b82f6" : "1px solid #e2e8f0";
|
||||||
|
${config.name}Style.borderRadius = "8px";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={\`${config.id}-layout \${isDesignMode ? "design-mode" : ""} \${className}\`}
|
||||||
|
style={${config.name}Style}
|
||||||
|
onClick={onClick}
|
||||||
|
draggable={isDesignMode}
|
||||||
|
onDragStart={props.onDragStart}
|
||||||
|
onDragEnd={props.onDragEnd}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{layout.zones.map((zone: any) => {
|
||||||
|
const zoneChildren = renderer.getZoneChildren(zone.id);
|
||||||
|
|
||||||
|
// TODO: 존별 스타일 정의
|
||||||
|
const zoneStyle: React.CSSProperties = {
|
||||||
|
// 레이아웃별 존 스타일 구현
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderer.renderZone(zone, zoneChildren, {
|
||||||
|
style: zoneStyle,
|
||||||
|
className: "${config.id}-zone",
|
||||||
|
});
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 디자인 모드에서 빈 영역 표시 */}
|
||||||
|
{isDesignMode && layout.zones.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="empty-${config.id}-container"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
border: "2px dashed #cbd5e1",
|
||||||
|
borderRadius: "8px",
|
||||||
|
backgroundColor: "rgba(148, 163, 184, 0.05)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#64748b",
|
||||||
|
minHeight: "100px",
|
||||||
|
padding: "20px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${config.name}에 존을 추가하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(layoutDir, `${config.className}Layout.tsx`), content);
|
||||||
|
console.log(`✅ ${config.className}Layout.tsx 생성됨`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레이아웃 렌더러 파일 생성
|
||||||
|
*/
|
||||||
|
function createLayoutRenderer() {
|
||||||
|
const content = `"use client";
|
||||||
|
|
||||||
|
import { AutoRegisteringLayoutRenderer } from "../AutoRegisteringLayoutRenderer";
|
||||||
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { ${config.className}LayoutDefinition } from "./index";
|
||||||
|
import { ${config.className}Layout } from "./${config.className}Layout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${config.name} 렌더러 (새 구조)
|
||||||
|
*/
|
||||||
|
export class ${config.className}LayoutRenderer extends AutoRegisteringLayoutRenderer {
|
||||||
|
/**
|
||||||
|
* 레이아웃 정의 (자동 등록용)
|
||||||
|
*/
|
||||||
|
static readonly layoutDefinition = ${config.className}LayoutDefinition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 클래스 로드 시 자동 등록 실행
|
||||||
|
*/
|
||||||
|
static {
|
||||||
|
this.registerSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 렌더링 실행
|
||||||
|
*/
|
||||||
|
render(): React.ReactElement {
|
||||||
|
return <${config.className}Layout {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React 함수 컴포넌트로 래핑 (외부 사용용)
|
||||||
|
*/
|
||||||
|
export const ${config.className}LayoutComponent: React.FC<LayoutRendererProps> = (props) => {
|
||||||
|
const renderer = new ${config.className}LayoutRenderer(props);
|
||||||
|
return renderer.render();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 개발 모드에서 Hot Reload 지원
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
// HMR API 등록
|
||||||
|
if ((module as any).hot) {
|
||||||
|
(module as any).hot.accept();
|
||||||
|
(module as any).hot.dispose(() => {
|
||||||
|
${config.className}LayoutRenderer.unregisterSelf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(layoutDir, `${config.className}LayoutRenderer.tsx`), content);
|
||||||
|
console.log(`✅ ${config.className}LayoutRenderer.tsx 생성됨`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 파일 생성
|
||||||
|
*/
|
||||||
|
function createConfigFile() {
|
||||||
|
const content = `/**
|
||||||
|
* ${config.name} 기본 설정
|
||||||
|
*/
|
||||||
|
export const ${config.className}LayoutConfig = {
|
||||||
|
defaultConfig: {
|
||||||
|
${config.id}: {
|
||||||
|
// TODO: 레이아웃 전용 설정 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing: 16,
|
||||||
|
// orientation: "vertical",
|
||||||
|
// allowResize: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
defaultZones: [${generateDefaultZones()}
|
||||||
|
],
|
||||||
|
|
||||||
|
// 설정 스키마 (검증용)
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
${config.id}: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
// TODO: 설정 스키마 정의
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["${config.id}"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(layoutDir, "config.ts"), content);
|
||||||
|
console.log("✅ config.ts 생성됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 타입 정의 파일 생성
|
||||||
|
*/
|
||||||
|
function createTypesFile() {
|
||||||
|
const content = `import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${config.name} 설정 타입
|
||||||
|
*/
|
||||||
|
export interface ${config.className}Config {
|
||||||
|
// TODO: 레이아웃 전용 설정 타입 정의
|
||||||
|
// 예시:
|
||||||
|
// spacing?: number;
|
||||||
|
// orientation?: "vertical" | "horizontal";
|
||||||
|
// allowResize?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${config.name} Props 타입
|
||||||
|
*/
|
||||||
|
export interface ${config.className}LayoutProps extends LayoutRendererProps {
|
||||||
|
renderer: any; // ${config.className}LayoutRenderer 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ${config.name} 존 타입
|
||||||
|
*/
|
||||||
|
export interface ${config.className}Zone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
// TODO: 존별 전용 속성 정의
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(layoutDir, "types.ts"), content);
|
||||||
|
console.log("✅ types.ts 생성됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* README 파일 생성
|
||||||
|
*/
|
||||||
|
function createReadme() {
|
||||||
|
const content = `# ${config.name}
|
||||||
|
|
||||||
|
${config.description}
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
이 레이아웃은 자동으로 등록되어 화면편집기에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 구성
|
||||||
|
|
||||||
|
- \`${config.className}Layout.tsx\`: 메인 레이아웃 컴포넌트
|
||||||
|
- \`${config.className}LayoutRenderer.tsx\`: 렌더러 (자동 등록)
|
||||||
|
- \`config.ts\`: 기본 설정
|
||||||
|
- \`types.ts\`: 타입 정의
|
||||||
|
- \`index.ts\`: 진입점
|
||||||
|
|
||||||
|
## 개발
|
||||||
|
|
||||||
|
1. \`${config.className}Layout.tsx\`에서 레이아웃 로직 구현
|
||||||
|
2. \`config.ts\`에서 기본 설정 조정
|
||||||
|
3. \`types.ts\`에서 타입 정의 추가
|
||||||
|
|
||||||
|
## 설정
|
||||||
|
|
||||||
|
\`\`\`typescript
|
||||||
|
{
|
||||||
|
${config.id}: {
|
||||||
|
// TODO: 설정 옵션 문서화
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## 존 구성
|
||||||
|
|
||||||
|
${generateZoneDocumentation()}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
생성일: ${new Date().toLocaleDateString()}
|
||||||
|
버전: 1.0.0
|
||||||
|
작성자: ${config.author}
|
||||||
|
`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(layoutDir, "README.md"), content);
|
||||||
|
console.log("✅ README.md 생성됨");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 존 생성
|
||||||
|
*/
|
||||||
|
function generateDefaultZones() {
|
||||||
|
const zones = [];
|
||||||
|
for (let i = 1; i <= config.zones; i++) {
|
||||||
|
zones.push(`
|
||||||
|
{
|
||||||
|
id: "zone${i}",
|
||||||
|
name: "존 ${i}",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
}`);
|
||||||
|
}
|
||||||
|
return zones.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존 문서화
|
||||||
|
*/
|
||||||
|
function generateZoneDocumentation() {
|
||||||
|
const docs = [];
|
||||||
|
for (let i = 1; i <= config.zones; i++) {
|
||||||
|
docs.push(`- **존 ${i}** (\`zone${i}\`): 기본 영역`);
|
||||||
|
}
|
||||||
|
return docs.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* package.json 스크립트 업데이트
|
||||||
|
*/
|
||||||
|
function updatePackageScripts() {
|
||||||
|
const packagePath = path.join(__dirname, "../package.json");
|
||||||
|
|
||||||
|
if (fs.existsSync(packagePath)) {
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||||
|
|
||||||
|
if (!packageJson.scripts) {
|
||||||
|
packageJson.scripts = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!packageJson.scripts["create-layout"]) {
|
||||||
|
packageJson.scripts["create-layout"] = "node scripts/create-layout.js";
|
||||||
|
fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2));
|
||||||
|
console.log("✅ package.json 스크립트 추가됨");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("⚠️ package.json 업데이트 실패:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,429 @@
|
||||||
|
// 레이아웃 기능 타입 정의
|
||||||
|
|
||||||
|
import { ComponentStyle, ComponentData, ComponentType } from "./screen";
|
||||||
|
|
||||||
|
// 레이아웃 타입 정의
|
||||||
|
export type LayoutType =
|
||||||
|
| "grid" // 그리드 레이아웃 (n x m 격자)
|
||||||
|
| "flexbox" // 플렉스박스 레이아웃
|
||||||
|
| "split" // 분할 레이아웃 (수직/수평)
|
||||||
|
| "card" // 카드 레이아웃
|
||||||
|
| "tabs" // 탭 레이아웃
|
||||||
|
| "accordion" // 아코디언 레이아웃
|
||||||
|
| "sidebar" // 사이드바 레이아웃
|
||||||
|
| "header-footer" // 헤더-푸터 레이아웃
|
||||||
|
| "three-column" // 3단 레이아웃
|
||||||
|
| "dashboard" // 대시보드 레이아웃
|
||||||
|
| "form" // 폼 레이아웃
|
||||||
|
| "table" // 테이블 레이아웃
|
||||||
|
| "custom"; // 커스텀 레이아웃
|
||||||
|
|
||||||
|
// 레이아웃 카테고리
|
||||||
|
export const LAYOUT_CATEGORIES = {
|
||||||
|
BASIC: "basic", // 기본 레이아웃
|
||||||
|
FORM: "form", // 폼 레이아웃
|
||||||
|
TABLE: "table", // 테이블 레이아웃
|
||||||
|
DASHBOARD: "dashboard", // 대시보드 레이아웃
|
||||||
|
NAVIGATION: "navigation", // 네비게이션 레이아웃
|
||||||
|
CONTENT: "content", // 컨텐츠 레이아웃
|
||||||
|
BUSINESS: "business", // 업무용 레이아웃
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LayoutCategory = (typeof LAYOUT_CATEGORIES)[keyof typeof LAYOUT_CATEGORIES];
|
||||||
|
|
||||||
|
// 레이아웃 존 정의
|
||||||
|
export interface LayoutZone {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: {
|
||||||
|
row?: number;
|
||||||
|
column?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
};
|
||||||
|
size: {
|
||||||
|
width: number | string;
|
||||||
|
height: number | string;
|
||||||
|
minWidth?: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
};
|
||||||
|
style?: ComponentStyle;
|
||||||
|
allowedComponents?: ComponentType[];
|
||||||
|
isResizable?: boolean;
|
||||||
|
isRequired?: boolean; // 필수 영역 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 설정
|
||||||
|
export interface LayoutConfig {
|
||||||
|
// 그리드 레이아웃 설정
|
||||||
|
grid?: {
|
||||||
|
rows: number;
|
||||||
|
columns: number;
|
||||||
|
gap: number;
|
||||||
|
rowGap?: number;
|
||||||
|
columnGap?: number;
|
||||||
|
autoRows?: string;
|
||||||
|
autoColumns?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 플렉스박스 설정
|
||||||
|
flexbox?: {
|
||||||
|
direction: "row" | "column" | "row-reverse" | "column-reverse";
|
||||||
|
justify: "flex-start" | "flex-end" | "center" | "space-between" | "space-around" | "space-evenly";
|
||||||
|
align: "flex-start" | "flex-end" | "center" | "stretch" | "baseline";
|
||||||
|
wrap: "nowrap" | "wrap" | "wrap-reverse";
|
||||||
|
gap: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 분할 레이아웃 설정
|
||||||
|
split?: {
|
||||||
|
direction: "horizontal" | "vertical";
|
||||||
|
ratio: number[]; // 각 영역의 비율 [30, 70]
|
||||||
|
minSize: number[]; // 각 영역의 최소 크기
|
||||||
|
resizable: boolean; // 크기 조절 가능 여부
|
||||||
|
splitterSize: number; // 분할선 두께
|
||||||
|
};
|
||||||
|
|
||||||
|
// 탭 레이아웃 설정
|
||||||
|
tabs?: {
|
||||||
|
position: "top" | "bottom" | "left" | "right";
|
||||||
|
variant: "default" | "pills" | "underline";
|
||||||
|
size: "sm" | "md" | "lg";
|
||||||
|
defaultTab: string; // 기본 선택 탭
|
||||||
|
closable: boolean; // 탭 닫기 가능 여부
|
||||||
|
};
|
||||||
|
|
||||||
|
// 아코디언 설정
|
||||||
|
accordion?: {
|
||||||
|
multiple: boolean; // 다중 확장 허용
|
||||||
|
defaultExpanded: string[]; // 기본 확장 항목
|
||||||
|
collapsible: boolean; // 모두 닫기 허용
|
||||||
|
};
|
||||||
|
|
||||||
|
// 사이드바 설정
|
||||||
|
sidebar?: {
|
||||||
|
position: "left" | "right";
|
||||||
|
width: number | string;
|
||||||
|
collapsible: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
overlay: boolean; // 오버레이 모드
|
||||||
|
};
|
||||||
|
|
||||||
|
// 헤더-푸터 설정
|
||||||
|
headerFooter?: {
|
||||||
|
headerHeight: number | string;
|
||||||
|
footerHeight: number | string;
|
||||||
|
stickyHeader: boolean;
|
||||||
|
stickyFooter: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 대시보드 설정
|
||||||
|
dashboard?: {
|
||||||
|
columns: number;
|
||||||
|
rowHeight: number;
|
||||||
|
margin: [number, number];
|
||||||
|
padding: [number, number];
|
||||||
|
isDraggable: boolean;
|
||||||
|
isResizable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 커스텀 설정
|
||||||
|
custom?: {
|
||||||
|
cssProperties: Record<string, string>;
|
||||||
|
className: string;
|
||||||
|
template: string; // HTML 템플릿
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드롭존 설정
|
||||||
|
export interface DropZoneConfig {
|
||||||
|
showDropZones: boolean;
|
||||||
|
dropZoneStyle?: ComponentStyle;
|
||||||
|
highlightOnDragOver: boolean;
|
||||||
|
allowedTypes?: ComponentType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트 인터페이스 (기존 screen.ts에 추가될 예정)
|
||||||
|
export interface LayoutComponent {
|
||||||
|
id: string;
|
||||||
|
type: "layout";
|
||||||
|
layoutType: LayoutType;
|
||||||
|
layoutConfig: LayoutConfig;
|
||||||
|
children: ComponentData[];
|
||||||
|
zones: LayoutZone[]; // 레이아웃 영역 정의
|
||||||
|
allowedComponentTypes?: ComponentType[]; // 허용된 자식 컴포넌트 타입
|
||||||
|
dropZoneConfig?: DropZoneConfig; // 드롭존 설정
|
||||||
|
position: { x: number; y: number };
|
||||||
|
size: { width: number; height: number };
|
||||||
|
parentId?: string;
|
||||||
|
style?: ComponentStyle;
|
||||||
|
tableName?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 표준 정의 (데이터베이스에서 조회)
|
||||||
|
export interface LayoutStandard {
|
||||||
|
layoutCode: string;
|
||||||
|
layoutName: string;
|
||||||
|
layoutNameEng?: string;
|
||||||
|
description?: string;
|
||||||
|
layoutType: LayoutType;
|
||||||
|
category: LayoutCategory;
|
||||||
|
iconName?: string;
|
||||||
|
defaultSize?: { width: number; height: number };
|
||||||
|
layoutConfig: LayoutConfig;
|
||||||
|
zonesConfig: LayoutZone[];
|
||||||
|
previewImage?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
isActive?: string;
|
||||||
|
isPublic?: string;
|
||||||
|
companyCode: string;
|
||||||
|
createdDate?: Date;
|
||||||
|
createdBy?: string;
|
||||||
|
updatedDate?: Date;
|
||||||
|
updatedBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 생성 요청
|
||||||
|
export interface CreateLayoutRequest {
|
||||||
|
layoutName: string;
|
||||||
|
layoutNameEng?: string;
|
||||||
|
description?: string;
|
||||||
|
layoutType: LayoutType;
|
||||||
|
category: LayoutCategory;
|
||||||
|
iconName?: string;
|
||||||
|
defaultSize?: { width: number; height: number };
|
||||||
|
layoutConfig: LayoutConfig;
|
||||||
|
zonesConfig: LayoutZone[];
|
||||||
|
isPublic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 수정 요청
|
||||||
|
export interface UpdateLayoutRequest extends Partial<CreateLayoutRequest> {
|
||||||
|
layoutCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레이아웃 정의 (레지스트리에서 사용)
|
||||||
|
export interface LayoutDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nameEng?: string;
|
||||||
|
description?: string;
|
||||||
|
category: LayoutCategory;
|
||||||
|
icon?: string;
|
||||||
|
component: React.ComponentType<any>;
|
||||||
|
defaultConfig: LayoutConfig;
|
||||||
|
defaultZones: LayoutZone[];
|
||||||
|
tags?: string[];
|
||||||
|
isActive?: boolean;
|
||||||
|
metadata?: {
|
||||||
|
version?: string;
|
||||||
|
author?: string;
|
||||||
|
documentation?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
lastUpdated?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사전 정의 레이아웃 템플릿
|
||||||
|
export const PREDEFINED_LAYOUTS: Omit<
|
||||||
|
LayoutStandard,
|
||||||
|
"layoutCode" | "companyCode" | "createdDate" | "createdBy" | "updatedDate" | "updatedBy"
|
||||||
|
>[] = [
|
||||||
|
// 기본 레이아웃
|
||||||
|
{
|
||||||
|
layoutName: "2x2 그리드",
|
||||||
|
layoutNameEng: "2x2 Grid",
|
||||||
|
layoutType: "grid",
|
||||||
|
category: "basic",
|
||||||
|
iconName: "grid",
|
||||||
|
defaultSize: { width: 800, height: 600 },
|
||||||
|
layoutConfig: {
|
||||||
|
grid: { rows: 2, columns: 2, gap: 16 },
|
||||||
|
},
|
||||||
|
zonesConfig: [
|
||||||
|
{
|
||||||
|
id: "zone1",
|
||||||
|
name: "상단 좌측",
|
||||||
|
position: { row: 0, column: 0 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone2",
|
||||||
|
name: "상단 우측",
|
||||||
|
position: { row: 0, column: 1 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone3",
|
||||||
|
name: "하단 좌측",
|
||||||
|
position: { row: 1, column: 0 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "zone4",
|
||||||
|
name: "하단 우측",
|
||||||
|
position: { row: 1, column: 1 },
|
||||||
|
size: { width: "50%", height: "50%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortOrder: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 폼 레이아웃
|
||||||
|
{
|
||||||
|
layoutName: "2단 폼 레이아웃",
|
||||||
|
layoutNameEng: "Two Column Form",
|
||||||
|
layoutType: "grid",
|
||||||
|
category: "form",
|
||||||
|
iconName: "columns",
|
||||||
|
defaultSize: { width: 800, height: 400 },
|
||||||
|
layoutConfig: {
|
||||||
|
grid: { rows: 1, columns: 2, gap: 24 },
|
||||||
|
},
|
||||||
|
zonesConfig: [
|
||||||
|
{
|
||||||
|
id: "left",
|
||||||
|
name: "좌측 입력 영역",
|
||||||
|
position: { row: 0, column: 0 },
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
name: "우측 입력 영역",
|
||||||
|
position: { row: 0, column: 1 },
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortOrder: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 대시보드 레이아웃
|
||||||
|
{
|
||||||
|
layoutName: "메인 대시보드",
|
||||||
|
layoutNameEng: "Main Dashboard",
|
||||||
|
layoutType: "grid",
|
||||||
|
category: "dashboard",
|
||||||
|
iconName: "layout-dashboard",
|
||||||
|
defaultSize: { width: 1200, height: 800 },
|
||||||
|
layoutConfig: {
|
||||||
|
grid: { rows: 2, columns: 4, gap: 16 },
|
||||||
|
},
|
||||||
|
zonesConfig: [
|
||||||
|
{
|
||||||
|
id: "header",
|
||||||
|
name: "헤더",
|
||||||
|
position: { row: 0, column: 0 },
|
||||||
|
size: { width: "100%", height: "80px" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sidebar",
|
||||||
|
name: "사이드바",
|
||||||
|
position: { row: 1, column: 0 },
|
||||||
|
size: { width: "250px", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "main",
|
||||||
|
name: "메인 컨텐츠",
|
||||||
|
position: { row: 1, column: 1 },
|
||||||
|
size: { width: "calc(100% - 250px)", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortOrder: 3,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 테이블 레이아웃
|
||||||
|
{
|
||||||
|
layoutName: "필터가 있는 테이블",
|
||||||
|
layoutNameEng: "Table with Filters",
|
||||||
|
layoutType: "flexbox",
|
||||||
|
category: "table",
|
||||||
|
iconName: "table",
|
||||||
|
defaultSize: { width: 1000, height: 600 },
|
||||||
|
layoutConfig: {
|
||||||
|
flexbox: { direction: "column", justify: "flex-start", align: "stretch", wrap: "nowrap", gap: 16 },
|
||||||
|
},
|
||||||
|
zonesConfig: [
|
||||||
|
{
|
||||||
|
id: "filters",
|
||||||
|
name: "검색 필터",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "auto" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "table",
|
||||||
|
name: "데이터 테이블",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "1fr" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortOrder: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 분할 레이아웃
|
||||||
|
{
|
||||||
|
layoutName: "수평 분할",
|
||||||
|
layoutNameEng: "Horizontal Split",
|
||||||
|
layoutType: "split",
|
||||||
|
category: "basic",
|
||||||
|
iconName: "separator-horizontal",
|
||||||
|
defaultSize: { width: 800, height: 400 },
|
||||||
|
layoutConfig: {
|
||||||
|
split: { direction: "horizontal", ratio: [50, 50], minSize: [200, 200], resizable: true, splitterSize: 4 },
|
||||||
|
},
|
||||||
|
zonesConfig: [
|
||||||
|
{
|
||||||
|
id: "left",
|
||||||
|
name: "좌측 영역",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
isResizable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "right",
|
||||||
|
name: "우측 영역",
|
||||||
|
position: {},
|
||||||
|
size: { width: "50%", height: "100%" },
|
||||||
|
isResizable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortOrder: 5,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 탭 레이아웃
|
||||||
|
{
|
||||||
|
layoutName: "수평 탭",
|
||||||
|
layoutNameEng: "Horizontal Tabs",
|
||||||
|
layoutType: "tabs",
|
||||||
|
category: "navigation",
|
||||||
|
iconName: "tabs",
|
||||||
|
defaultSize: { width: 800, height: 500 },
|
||||||
|
layoutConfig: {
|
||||||
|
tabs: { position: "top", variant: "default", size: "md", defaultTab: "tab1", closable: false },
|
||||||
|
},
|
||||||
|
zonesConfig: [
|
||||||
|
{
|
||||||
|
id: "tab1",
|
||||||
|
name: "첫 번째 탭",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab2",
|
||||||
|
name: "두 번째 탭",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tab3",
|
||||||
|
name: "세 번째 탭",
|
||||||
|
position: {},
|
||||||
|
size: { width: "100%", height: "100%" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sortOrder: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
// 화면관리 시스템 타입 정의
|
// 화면관리 시스템 타입 정의
|
||||||
|
|
||||||
// 기본 컴포넌트 타입
|
// 기본 컴포넌트 타입
|
||||||
export type ComponentType = "container" | "row" | "column" | "widget" | "group" | "datatable" | "file" | "area";
|
export type ComponentType =
|
||||||
|
| "container"
|
||||||
|
| "row"
|
||||||
|
| "column"
|
||||||
|
| "widget"
|
||||||
|
| "group"
|
||||||
|
| "datatable"
|
||||||
|
| "file"
|
||||||
|
| "area"
|
||||||
|
| "layout";
|
||||||
|
|
||||||
// 웹 타입 정의
|
// 웹 타입 정의
|
||||||
export type WebType =
|
export type WebType =
|
||||||
|
|
@ -488,7 +497,8 @@ export type ComponentData =
|
||||||
| AreaComponent
|
| AreaComponent
|
||||||
| WidgetComponent
|
| WidgetComponent
|
||||||
| DataTableComponent
|
| DataTableComponent
|
||||||
| FileComponent;
|
| FileComponent
|
||||||
|
| LayoutComponent;
|
||||||
|
|
||||||
// 레이아웃 데이터
|
// 레이아웃 데이터
|
||||||
export interface LayoutData {
|
export interface LayoutData {
|
||||||
|
|
@ -541,6 +551,17 @@ export interface CreateScreenRequest {
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 레이아웃 컴포넌트 (layout.ts에서 import)
|
||||||
|
export interface LayoutComponent extends BaseComponent {
|
||||||
|
type: "layout";
|
||||||
|
layoutType: import("./layout").LayoutType;
|
||||||
|
layoutConfig: import("./layout").LayoutConfig;
|
||||||
|
children: ComponentData[];
|
||||||
|
zones: import("./layout").LayoutZone[];
|
||||||
|
allowedComponentTypes?: ComponentType[];
|
||||||
|
dropZoneConfig?: import("./layout").DropZoneConfig;
|
||||||
|
}
|
||||||
|
|
||||||
// 화면 수정 요청
|
// 화면 수정 요청
|
||||||
export interface UpdateScreenRequest {
|
export interface UpdateScreenRequest {
|
||||||
screenName?: string;
|
screenName?: string;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue