Files
invyone/frontend/components/builder/BuilderBlock.tsx
T
2026-04-10 13:33:37 +09:00

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>
);
}