169 lines
6.2 KiB
TypeScript
169 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import React, { useMemo } from "react";
|
|
import { useBuilderState } from "./hooks/useBuilderState";
|
|
import { useBlockDrag } from "./hooks/useBlockDrag";
|
|
import type { Component, TableConfig, FormConfig, SearchConfig, TitleConfig, ButtonConfig, ButtonBarConfig } from "@/types/invyone-component";
|
|
import type { FieldConfig } from "@/types/invyone-component";
|
|
|
|
interface BuilderBlockProps {
|
|
block: Component;
|
|
}
|
|
|
|
/** 캔버스 위의 개별 블록 — 드래그 이동 + 리사이즈 + 프리뷰 */
|
|
export default function BuilderBlock({ block }: BuilderBlockProps) {
|
|
const selectedBlockId = useBuilderState((s) => s.selectedBlockId);
|
|
const fields = useBuilderState((s) => s.fields);
|
|
const selectBlock = useBuilderState((s) => s.selectBlock);
|
|
const { startDrag, startResize } = useBlockDrag();
|
|
|
|
const isSelected = selectedBlockId === block.id;
|
|
const { x, y, w, h } = block.position;
|
|
|
|
return (
|
|
<div
|
|
className={`dev-block${isSelected ? " selected" : ""}`}
|
|
style={{ left: x, top: y, width: w, height: h }}
|
|
onMouseDown={(e) => {
|
|
if ((e.target as HTMLElement).classList.contains("dev-resize-handle")) return;
|
|
startDrag(e, block.id, x, y, w, h);
|
|
}}
|
|
onClick={(e) => { e.stopPropagation(); selectBlock(block.id); }}
|
|
>
|
|
<div className="dev-block-label">{block.label}</div>
|
|
<div className="dev-block-content">
|
|
<BlockPreview block={block} fields={fields} />
|
|
</div>
|
|
<div
|
|
className="dev-resize-handle"
|
|
onMouseDown={(e) => startResize(e, block.id, x, y, w, h)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** 블록 내부 프리뷰 렌더 (타입별 분기) */
|
|
function BlockPreview({ block, fields }: { block: Component; fields: FieldConfig[] }) {
|
|
const visibleFields = useMemo(
|
|
() => fields.filter((f) => f.visible && !f.system).sort((a, b) => a.order - b.order),
|
|
[fields]
|
|
);
|
|
|
|
switch (block.type) {
|
|
case "table":
|
|
return <TablePreview fields={visibleFields} />;
|
|
case "form":
|
|
return <FormPreview fields={visibleFields} config={block.config as FormConfig} />;
|
|
case "search":
|
|
return <SearchPreview fields={fields.filter((f) => f.searchable && !f.system)} />;
|
|
case "title":
|
|
return <TitlePreview config={block.config as TitleConfig} />;
|
|
case "button":
|
|
return <ButtonPreview config={block.config as ButtonConfig} />;
|
|
case "button-bar":
|
|
return <ButtonBarPreview config={block.config as ButtonBarConfig} />;
|
|
case "pagination":
|
|
return <PaginationPreview />;
|
|
case "divider":
|
|
return <div style={{ borderTop: "1px solid var(--d-border)", margin: "0.3rem 0" }} />;
|
|
case "stats":
|
|
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>통계 카드 프리뷰</div>;
|
|
default:
|
|
return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>{block.type}</div>;
|
|
}
|
|
}
|
|
|
|
function TablePreview({ fields }: { fields: FieldConfig[] }) {
|
|
const cols = fields.slice(0, 8);
|
|
if (!cols.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>테이블을 선택하세요</div>;
|
|
return (
|
|
<table className="dev-pv-table">
|
|
<thead>
|
|
<tr>{cols.map((f) => <th key={f.column}>{f.label}</th>)}</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[0, 1, 2].map((r) => (
|
|
<tr key={r}>{cols.map((f) => <td key={f.column}>—</td>)}</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
);
|
|
}
|
|
|
|
function FormPreview({ fields, config }: { fields: FieldConfig[]; config: FormConfig }) {
|
|
const cols = config?.columns || 2;
|
|
const formFields = fields.filter((f) => !f.pk || f.type !== "code");
|
|
return (
|
|
<div style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: "0.2rem 0.4rem" }}>
|
|
{formFields.slice(0, 10).map((f) => (
|
|
<div className="dev-pv-field" key={f.column}>
|
|
<div className="dev-pv-field-label">
|
|
{f.label}{f.required && <span style={{ color: "var(--d-red)" }}> *</span>}
|
|
</div>
|
|
<div className="dev-pv-field-input">
|
|
{f.type === "select"
|
|
? (typeof f.options?.[0] === "string" ? f.options[0] : typeof f.options?.[0] === "object" ? f.options[0].label : "—")
|
|
: "—"}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SearchPreview({ fields }: { fields: FieldConfig[] }) {
|
|
if (!fields.length) return <div style={{ fontSize: "0.44rem", color: "var(--d-text3)" }}>검색 조건 없음</div>;
|
|
return (
|
|
<div className="dev-pv-search">
|
|
{fields.slice(0, 5).map((f) => (
|
|
<div className="dev-pv-search-item" key={f.column}>
|
|
<div className="dev-pv-search-label">{f.label}</div>
|
|
<div className="dev-pv-search-input">—</div>
|
|
</div>
|
|
))}
|
|
<div className="dev-pv-search-item" style={{ justifyContent: "flex-end" }}>
|
|
<button className="dev-pv-btn primary" style={{ marginTop: "auto" }}>검색</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TitlePreview({ config }: { config: TitleConfig }) {
|
|
return (
|
|
<div style={{ fontSize: config.fontSize, fontWeight: config.fontWeight, textAlign: config.align, color: "var(--d-text)" }}>
|
|
{config.text || "제목"}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ButtonPreview({ config }: { config: ButtonConfig }) {
|
|
const cls = config.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn";
|
|
return <div className={cls}>{config.text || "버튼"}</div>;
|
|
}
|
|
|
|
function ButtonBarPreview({ config }: { config: ButtonBarConfig }) {
|
|
return (
|
|
<div style={{ display: "flex", gap: "0.2rem", padding: "0.2rem" }}>
|
|
{(config.buttons || []).map((btn, i) => (
|
|
<div key={i} className={btn.variant === "primary" ? "dev-pv-btn primary" : "dev-pv-btn"}>
|
|
{btn.text}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PaginationPreview() {
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", fontSize: "0.42rem", color: "var(--d-text3)", padding: "0.1rem" }}>
|
|
<span>총 0건</span>
|
|
<div style={{ display: "flex", gap: "0.15rem" }}>
|
|
<span style={{ padding: "0.1rem 0.25rem", borderRadius: 3, background: "var(--d-accent)", color: "#fff" }}>1</span>
|
|
<span style={{ padding: "0.1rem 0.25rem" }}>2</span>
|
|
<span style={{ padding: "0.1rem 0.25rem" }}>3</span>
|
|
</div>
|
|
<span>20건/페이지</span>
|
|
</div>
|
|
);
|
|
}
|