ERP-node/frontend/lib/registry/pop-components/pop-text.tsx

832 lines
25 KiB
TypeScript

"use client";
import React, { useState, useEffect } from "react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import {
FontSize,
FontWeight,
TextAlign,
ObjectFit,
VerticalAlign,
FONT_SIZE_LABELS,
FONT_WEIGHT_LABELS,
OBJECT_FIT_LABELS,
FONT_SIZE_CLASSES,
FONT_WEIGHT_CLASSES,
TEXT_ALIGN_CLASSES,
VERTICAL_ALIGN_LABELS,
VERTICAL_ALIGN_CLASSES,
JUSTIFY_CLASSES,
} from "./types";
// ========================================
// 타입 정의
// ========================================
export type PopTextType = "text" | "datetime" | "image" | "title";
// datetime 빌더 설정 타입
export interface DateTimeBuilderConfig {
// 날짜 요소
showYear?: boolean;
showMonth?: boolean;
showDay?: boolean;
showWeekday?: boolean;
// 시간 요소
showHour?: boolean;
showMinute?: boolean;
showSecond?: boolean;
// 표기 방식
useKorean?: boolean; // true: 한글 (02월 04일), false: 숫자 (02/04)
// 구분자
dateSeparator?: string; // "-", "/", "."
}
export interface PopTextConfig {
textType: PopTextType;
content?: string;
dateFormat?: string; // 기존 호환용 (deprecated)
dateTimeConfig?: DateTimeBuilderConfig; // 새로운 빌더 설정
isRealtime?: boolean;
imageUrl?: string;
objectFit?: ObjectFit;
imageScale?: number; // 이미지 크기 조정 (10-100%)
fontSize?: FontSize;
fontWeight?: FontWeight;
textAlign?: TextAlign;
verticalAlign?: VerticalAlign; // 상하 정렬
}
const TEXT_TYPE_LABELS: Record<PopTextType, string> = {
text: "일반 텍스트",
datetime: "시간/날짜",
image: "이미지",
title: "제목",
};
// ========================================
// datetime 포맷 빌드 함수
// ========================================
function buildDateTimeFormat(config?: DateTimeBuilderConfig): string {
// 설정이 없으면 기본값 (시:분:초)
if (!config) return "HH:mm:ss";
const sep = config.dateSeparator || "-";
const parts: string[] = [];
// 날짜 부분 조합
const hasDateParts = config.showYear || config.showMonth || config.showDay;
if (hasDateParts) {
const dateParts: string[] = [];
if (config.showYear) dateParts.push(config.useKorean ? "yyyy년" : "yyyy");
if (config.showMonth) dateParts.push(config.useKorean ? "MM월" : "MM");
if (config.showDay) dateParts.push(config.useKorean ? "dd일" : "dd");
// 한글 모드: 공백으로 연결, 숫자 모드: 구분자로 연결
parts.push(config.useKorean ? dateParts.join(" ") : dateParts.join(sep));
}
// 요일
if (config.showWeekday) {
parts.push(config.useKorean ? "(EEEE)" : "(EEE)");
}
// 시간 부분 조합
const timeParts: string[] = [];
if (config.showHour) timeParts.push(config.useKorean ? "HH시" : "HH");
if (config.showMinute) timeParts.push(config.useKorean ? "mm분" : "mm");
if (config.showSecond) timeParts.push(config.useKorean ? "ss초" : "ss");
if (timeParts.length > 0) {
// 한글 모드: 공백으로 연결, 숫자 모드: 콜론으로 연결
parts.push(config.useKorean ? timeParts.join(" ") : timeParts.join(":"));
}
// 아무것도 선택 안 했으면 기본값
return parts.join(" ") || "HH:mm:ss";
}
// ========================================
// 메인 컴포넌트
// ========================================
interface PopTextComponentProps {
config?: PopTextConfig;
label?: string;
isDesignMode?: boolean;
}
export function PopTextComponent({
config,
label,
isDesignMode,
}: PopTextComponentProps) {
const textType = config?.textType || "text";
if (isDesignMode) {
return (
<div className="flex h-full w-full items-center justify-center">
<DesignModePreview config={config} label={label} />
</div>
);
}
// 실제 렌더링
switch (textType) {
case "datetime":
return <DateTimeDisplay config={config} />;
case "image":
return <ImageDisplay config={config} />;
case "title":
return <TitleDisplay config={config} label={label} />;
default:
return <TextDisplay config={config} label={label} />;
}
}
// 디자인 모드 미리보기 (실제 설정값 표시)
function DesignModePreview({
config,
label,
}: {
config?: PopTextConfig;
label?: string;
}) {
const textType = config?.textType || "text";
// 공통 정렬 래퍼 클래스 (상하좌우 정렬)
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
switch (textType) {
case "datetime":
// 실시간 시간 미리보기
return (
<div className={alignWrapperClass}>
<DateTimePreview config={config} />
</div>
);
case "image":
// 이미지 미리보기
if (!config?.imageUrl) {
return (
<div className="flex h-full w-full items-center justify-center border border-dashed border-gray-300 text-[10px] text-gray-400">
URL
</div>
);
}
// 이미지도 정렬 래퍼 적용
return (
<div className={alignWrapperClass}>
<img
src={config.imageUrl}
alt=""
style={{
objectFit: config.objectFit || "none",
width: `${config.imageScale || 100}%`,
height: `${config.imageScale || 100}%`,
}}
/>
</div>
);
case "title":
// 제목 미리보기
return (
<div className={alignWrapperClass}>
<span
className={cn(
"whitespace-pre-wrap",
FONT_SIZE_CLASSES[config?.fontSize || "lg"],
FONT_WEIGHT_CLASSES[config?.fontWeight || "bold"]
)}
>
{config?.content || label || "제목"}
</span>
</div>
);
default:
// 일반 텍스트 미리보기
return (
<div className={alignWrapperClass}>
<span
className={cn(
"whitespace-pre-wrap",
FONT_SIZE_CLASSES[config?.fontSize || "base"]
)}
>
{config?.content || label || "텍스트"}
</span>
</div>
);
}
}
// 디자인 모드용 시간 미리보기 (실시간)
function DateTimePreview({ config }: { config?: PopTextConfig }) {
const [now, setNow] = useState(new Date());
useEffect(() => {
// 디자인 모드에서도 실시간 업데이트 (간격 늘림)
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
const dateFormat = config?.dateTimeConfig
? buildDateTimeFormat(config.dateTimeConfig)
: config?.dateFormat || "HH:mm:ss";
return (
<span
className={cn(
"font-mono text-gray-600",
FONT_SIZE_CLASSES[config?.fontSize || "base"]
)}
>
{format(now, dateFormat, { locale: ko })}
</span>
);
}
// 시간/날짜 (실시간 지원)
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
const [now, setNow] = useState(new Date());
useEffect(() => {
if (!config?.isRealtime) return;
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, [config?.isRealtime]);
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
const dateFormat = config?.dateTimeConfig
? buildDateTimeFormat(config.dateTimeConfig)
: config?.dateFormat || "HH:mm:ss";
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<span
className={cn("font-mono", FONT_SIZE_CLASSES[config?.fontSize || "base"])}
>
{format(now, dateFormat, { locale: ko })}
</span>
</div>
);
}
// 이미지
function ImageDisplay({ config }: { config?: PopTextConfig }) {
if (!config?.imageUrl) {
return (
<div className="flex h-full items-center justify-center border-2 border-dashed text-xs text-gray-400">
URL
</div>
);
}
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<img
src={config.imageUrl}
alt=""
style={{
objectFit: config.objectFit || "none",
width: `${config?.imageScale || 100}%`,
height: `${config?.imageScale || 100}%`,
}}
/>
</div>
);
}
// 제목
function TitleDisplay({
config,
label,
}: {
config?: PopTextConfig;
label?: string;
}) {
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
const weightClass = FONT_WEIGHT_CLASSES[config?.fontWeight || "normal"];
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<span className={cn("whitespace-pre-wrap", sizeClass, weightClass)}>
{config?.content || label || "제목"}
</span>
</div>
);
}
// 일반 텍스트
function TextDisplay({
config,
label,
}: {
config?: PopTextConfig;
label?: string;
}) {
const sizeClass = FONT_SIZE_CLASSES[config?.fontSize || "base"];
// 정렬 래퍼 클래스
const alignWrapperClass = cn(
"flex w-full h-full",
VERTICAL_ALIGN_CLASSES[config?.verticalAlign || "center"],
JUSTIFY_CLASSES[config?.textAlign || "left"]
);
return (
<div className={alignWrapperClass}>
<span className={cn("whitespace-pre-wrap", sizeClass)}>
{config?.content || label || "텍스트"}
</span>
</div>
);
}
// ========================================
// 설정 패널
// ========================================
interface PopTextConfigPanelProps {
config: PopTextConfig;
onUpdate: (config: PopTextConfig) => void;
}
export function PopTextConfigPanel({
config,
onUpdate,
}: PopTextConfigPanelProps) {
const textType = config?.textType || "text";
return (
<div className="space-y-4">
{/* 텍스트 타입 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={textType}
onValueChange={(v) =>
onUpdate({ ...config, textType: v as PopTextType })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(TEXT_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 서브타입별 설정 */}
{textType === "text" && (
<>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Textarea
value={config?.content || ""}
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
placeholder="여러 줄 입력 가능"
rows={3}
className="text-xs resize-none"
/>
</div>
<FontSizeSelect config={config} onUpdate={onUpdate} />
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
{textType === "datetime" && (
<>
{/* 포맷 빌더 UI */}
<DateTimeFormatBuilder config={config} onUpdate={onUpdate} />
{/* 실시간 업데이트 */}
<div className="flex items-center gap-2">
<Switch
checked={config?.isRealtime ?? true}
onCheckedChange={(v) => onUpdate({ ...config, isRealtime: v })}
/>
<Label className="text-xs"> </Label>
</div>
<FontSizeSelect config={config} onUpdate={onUpdate} />
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
{textType === "image" && (
<>
<div className="space-y-2">
<Label className="text-xs"> URL</Label>
<Input
value={config?.imageUrl || ""}
onChange={(e) =>
onUpdate({ ...config, imageUrl: e.target.value })
}
placeholder="https://..."
className="h-8 text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config?.objectFit || "none"}
onValueChange={(v) =>
onUpdate({ ...config, objectFit: v as ObjectFit })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(OBJECT_FIT_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">
: {config?.imageScale || 100}%
</Label>
<input
type="range"
min={10}
max={100}
step={10}
value={config?.imageScale || 100}
onChange={(e) =>
onUpdate({ ...config, imageScale: Number(e.target.value) })
}
className="w-full"
/>
</div>
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
{textType === "title" && (
<>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
value={config?.content || ""}
onChange={(e) => onUpdate({ ...config, content: e.target.value })}
placeholder="제목 입력"
className="h-8 text-xs"
/>
</div>
<FontSizeSelect config={config} onUpdate={onUpdate} />
<FontWeightSelect config={config} onUpdate={onUpdate} />
<AlignmentSelect config={config} onUpdate={onUpdate} />
</>
)}
</div>
);
}
// 공통: 글자 크기
function FontSizeSelect({ config, onUpdate }: PopTextConfigPanelProps) {
return (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config?.fontSize || "base"}
onValueChange={(v) => onUpdate({ ...config, fontSize: v as FontSize })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FONT_SIZE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// 공통: 글자 굵기
function FontWeightSelect({ config, onUpdate }: PopTextConfigPanelProps) {
return (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config?.fontWeight || "normal"}
onValueChange={(v) =>
onUpdate({ ...config, fontWeight: v as FontWeight })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FONT_WEIGHT_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value} className="text-xs">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// datetime 포맷 빌더 UI
function DateTimeFormatBuilder({ config, onUpdate }: PopTextConfigPanelProps) {
// 기본값 설정 (시:분:초)
const dtConfig: DateTimeBuilderConfig = config?.dateTimeConfig || {
showHour: true,
showMinute: true,
showSecond: true,
useKorean: false,
dateSeparator: "-",
};
// dateTimeConfig 업데이트 헬퍼
const updateDtConfig = (updates: Partial<DateTimeBuilderConfig>) => {
onUpdate({
...config,
dateTimeConfig: { ...dtConfig, ...updates },
});
};
// 날짜 요소가 하나라도 선택되었는지
const hasDateParts = dtConfig.showYear || dtConfig.showMonth || dtConfig.showDay;
// 미리보기용 포맷 생성
const previewFormat = buildDateTimeFormat(dtConfig);
const previewText = format(new Date(), previewFormat, { locale: ko });
return (
<div className="space-y-3">
{/* 날짜 요소 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="flex flex-wrap gap-3">
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showYear || false}
onCheckedChange={(checked) =>
updateDtConfig({ showYear: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showMonth || false}
onCheckedChange={(checked) =>
updateDtConfig({ showMonth: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showDay || false}
onCheckedChange={(checked) =>
updateDtConfig({ showDay: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showWeekday || false}
onCheckedChange={(checked) =>
updateDtConfig({ showWeekday: checked === true })
}
/>
</label>
</div>
</div>
{/* 시간 요소 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<div className="flex flex-wrap gap-3">
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showHour || false}
onCheckedChange={(checked) =>
updateDtConfig({ showHour: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showMinute || false}
onCheckedChange={(checked) =>
updateDtConfig({ showMinute: checked === true })
}
/>
</label>
<label className="flex items-center gap-1.5 text-xs">
<Checkbox
checked={dtConfig.showSecond || false}
onCheckedChange={(checked) =>
updateDtConfig({ showSecond: checked === true })
}
/>
</label>
</div>
</div>
{/* 표기 방식 */}
<div className="space-y-1">
<Label className="text-xs"> </Label>
<RadioGroup
value={dtConfig.useKorean ? "korean" : "number"}
onValueChange={(v) => updateDtConfig({ useKorean: v === "korean" })}
className="flex gap-4"
>
<label className="flex items-center gap-1.5 text-xs">
<RadioGroupItem value="number" />
(02/04)
</label>
<label className="flex items-center gap-1.5 text-xs">
<RadioGroupItem value="korean" />
(02 04)
</label>
</RadioGroup>
</div>
{/* 구분자 (숫자 모드 + 날짜 요소가 있을 때만) */}
{!dtConfig.useKorean && hasDateParts && (
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={dtConfig.dateSeparator || "-"}
onValueChange={(v) => updateDtConfig({ dateSeparator: v })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="-" className="text-xs">
- ()
</SelectItem>
<SelectItem value="/" className="text-xs">
/ ()
</SelectItem>
<SelectItem value="." className="text-xs">
. ()
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 미리보기 */}
<div className="rounded border bg-muted/50 p-2">
<span className="text-[10px] text-muted-foreground">: </span>
<span className="text-xs font-medium">{previewText}</span>
</div>
</div>
);
}
// 공통: 정렬 (좌우 + 상하)
function AlignmentSelect({ config, onUpdate }: PopTextConfigPanelProps) {
return (
<div className="space-y-0">
<Label className="text-xs"></Label>
<div className="grid grid-cols-2 gap-2">
{/* 좌우 정렬 */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground">/</span>
<Select
value={config?.textAlign || "left"}
onValueChange={(v) =>
onUpdate({ ...config, textAlign: v as TextAlign })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="좌우" />
</SelectTrigger>
<SelectContent>
<SelectItem value="left" className="text-xs">
</SelectItem>
<SelectItem value="center" className="text-xs">
</SelectItem>
<SelectItem value="right" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 상하 정렬 */}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground">/</span>
<Select
value={config?.verticalAlign || "center"}
onValueChange={(v) =>
onUpdate({ ...config, verticalAlign: v as VerticalAlign })
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="상하" />
</SelectTrigger>
<SelectContent>
<SelectItem value="top" className="text-xs">
</SelectItem>
<SelectItem value="center" className="text-xs">
</SelectItem>
<SelectItem value="bottom" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
// ========================================
// 디자이너 미리보기 컴포넌트
// ========================================
function PopTextPreviewComponent({ config }: { config?: PopTextConfig }) {
return (
<div className="flex h-full w-full items-center justify-center overflow-hidden">
<DesignModePreview config={config} />
</div>
);
}
// ========================================
// 레지스트리 등록
// ========================================
PopComponentRegistry.registerComponent({
id: "pop-text",
name: "텍스트",
description: "텍스트, 시간, 이미지 표시",
category: "display",
icon: "FileText",
component: PopTextComponent,
configPanel: PopTextConfigPanel,
preview: PopTextPreviewComponent,
defaultProps: { textType: "text", fontSize: "base" },
touchOptimized: true,
supportedDevices: ["mobile", "tablet"],
});