510 lines
20 KiB
TypeScript
510 lines
20 KiB
TypeScript
"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 { Checkbox } from "@/components/ui/checkbox";
|
||
import {
|
||
Database,
|
||
ArrowLeft,
|
||
Save,
|
||
Monitor,
|
||
Smartphone,
|
||
Tablet,
|
||
ChevronDown,
|
||
Settings,
|
||
Grid3X3,
|
||
Eye,
|
||
EyeOff,
|
||
Zap,
|
||
Languages,
|
||
Settings2,
|
||
PanelLeft,
|
||
PanelLeftClose,
|
||
AlignStartVertical,
|
||
AlignCenterVertical,
|
||
AlignEndVertical,
|
||
AlignStartHorizontal,
|
||
AlignCenterHorizontal,
|
||
AlignEndHorizontal,
|
||
AlignHorizontalSpaceAround,
|
||
AlignVerticalSpaceAround,
|
||
RulerIcon,
|
||
Tag,
|
||
Keyboard,
|
||
Equal,
|
||
} from "lucide-react";
|
||
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuLabel,
|
||
DropdownMenuSeparator,
|
||
DropdownMenuTrigger,
|
||
} from "@/components/ui/dropdown-menu";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
|
||
interface GridSettings {
|
||
columns: number;
|
||
gap: number;
|
||
padding: number;
|
||
snapToGrid: boolean;
|
||
showGrid: boolean;
|
||
gridColor?: string;
|
||
gridOpacity?: number;
|
||
}
|
||
|
||
type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
|
||
type DistributeDirection = "horizontal" | "vertical";
|
||
type MatchSizeMode = "width" | "height" | "both";
|
||
|
||
interface SlimToolbarProps {
|
||
screenName?: string;
|
||
tableName?: string;
|
||
screenResolution?: ScreenResolution;
|
||
onBack: () => void;
|
||
onSave: () => void;
|
||
isSaving?: boolean;
|
||
onMigrateToV3?: () => void;
|
||
isMigrating?: boolean;
|
||
onPreview?: () => void;
|
||
onResolutionChange?: (resolution: ScreenResolution) => void;
|
||
gridSettings?: GridSettings;
|
||
onGridSettingsChange?: (settings: GridSettings) => void;
|
||
onGenerateMultilang?: () => void;
|
||
isGeneratingMultilang?: boolean;
|
||
onOpenMultilangSettings?: () => void;
|
||
// 패널 토글 기능
|
||
isPanelOpen?: boolean;
|
||
onTogglePanel?: () => void;
|
||
// 정렬/배분/크기 기능
|
||
selectedCount?: number;
|
||
onAlign?: (mode: AlignMode) => void;
|
||
onDistribute?: (direction: DistributeDirection) => void;
|
||
onMatchSize?: (mode: MatchSizeMode) => void;
|
||
onToggleLabels?: () => void;
|
||
onShowShortcuts?: () => void;
|
||
}
|
||
|
||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||
screenName,
|
||
tableName,
|
||
screenResolution,
|
||
onBack,
|
||
onSave,
|
||
isSaving = false,
|
||
onMigrateToV3,
|
||
isMigrating = false,
|
||
onPreview,
|
||
onResolutionChange,
|
||
gridSettings,
|
||
onGridSettingsChange,
|
||
onGenerateMultilang,
|
||
isGeneratingMultilang = false,
|
||
onOpenMultilangSettings,
|
||
isPanelOpen = false,
|
||
onTogglePanel,
|
||
selectedCount = 0,
|
||
onAlign,
|
||
onDistribute,
|
||
onMatchSize,
|
||
onToggleLabels,
|
||
onShowShortcuts,
|
||
}) => {
|
||
// 사용자 정의 해상도 상태
|
||
const [customWidth, setCustomWidth] = useState("");
|
||
const [customHeight, setCustomHeight] = useState("");
|
||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||
|
||
const getCategoryIcon = (category: string) => {
|
||
switch (category) {
|
||
case "desktop":
|
||
return <Monitor className="h-4 w-4 text-primary" />;
|
||
case "tablet":
|
||
return <Tablet className="h-4 w-4 text-green-500" />;
|
||
case "mobile":
|
||
return <Smartphone className="h-4 w-4 text-purple-500" />;
|
||
default:
|
||
return <Monitor className="h-4 w-4 text-primary" />;
|
||
}
|
||
};
|
||
|
||
const handleCustomResolution = () => {
|
||
const width = parseInt(customWidth);
|
||
const height = parseInt(customHeight);
|
||
if (width > 0 && height > 0 && onResolutionChange) {
|
||
const customResolution: ScreenResolution = {
|
||
width,
|
||
height,
|
||
name: `사용자 정의 (${width}×${height})`,
|
||
category: "custom",
|
||
};
|
||
onResolutionChange(customResolution);
|
||
setShowCustomInput(false);
|
||
}
|
||
};
|
||
|
||
const updateGridSetting = (key: keyof GridSettings, value: boolean) => {
|
||
if (onGridSettingsChange && gridSettings) {
|
||
onGridSettingsChange({
|
||
...gridSettings,
|
||
[key]: value,
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-12 items-center justify-between border-b bg-background px-4 shadow-sm">
|
||
{/* 좌측: 네비게이션 + 패널 토글 + 화면 정보 */}
|
||
<div className="flex items-center gap-3">
|
||
{/* 네비게이션 그룹 */}
|
||
<Button variant="ghost" size="sm" onClick={onBack} className="flex items-center gap-2">
|
||
<ArrowLeft className="h-4 w-4" />
|
||
<span className="hidden sm:inline">목록으로</span>
|
||
</Button>
|
||
|
||
{onTogglePanel && <div className="h-5 w-px bg-border" />}
|
||
|
||
{/* 뷰 컨트롤 그룹 */}
|
||
{onTogglePanel && (
|
||
<Button
|
||
variant={isPanelOpen ? "default" : "outline"}
|
||
size="sm"
|
||
onClick={onTogglePanel}
|
||
className="flex items-center gap-2"
|
||
title="패널 열기/닫기 (P)"
|
||
>
|
||
{isPanelOpen ? (
|
||
<PanelLeftClose className="h-4 w-4" />
|
||
) : (
|
||
<PanelLeft className="h-4 w-4" />
|
||
)}
|
||
<span className="hidden sm:inline">패널</span>
|
||
</Button>
|
||
)}
|
||
|
||
<div className="h-5 w-px bg-border" />
|
||
|
||
{/* 화면 정보 */}
|
||
<div className="flex items-center gap-3">
|
||
<div>
|
||
<h1 className="text-base font-semibold text-foreground sm:text-lg">{screenName || "화면 설계"}</h1>
|
||
{tableName && (
|
||
<div className="mt-0.5 flex items-center gap-1">
|
||
<Database className="h-3 w-3 text-muted-foreground" />
|
||
<span className="font-mono text-xs text-muted-foreground">{tableName}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 해상도 도구 그룹 */}
|
||
{screenResolution && (
|
||
<>
|
||
<div className="h-5 w-px bg-border" />
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button className="flex items-center gap-2 rounded-md border bg-secondary/50 px-3 py-1.5 text-xs transition-colors hover:bg-secondary sm:text-sm">
|
||
{getCategoryIcon(screenResolution.category || "desktop")}
|
||
<span className="hidden font-medium text-foreground sm:inline">{screenResolution.name}</span>
|
||
<span className="text-muted-foreground">
|
||
({screenResolution.width} × {screenResolution.height})
|
||
</span>
|
||
{onResolutionChange && <ChevronDown className="h-3 w-3 text-muted-foreground" />}
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
{onResolutionChange && (
|
||
<DropdownMenuContent align="start" className="w-64">
|
||
<DropdownMenuLabel className="text-xs text-muted-foreground">데스크톱</DropdownMenuLabel>
|
||
{SCREEN_RESOLUTIONS.filter((r) => r.category === "desktop").map((resolution) => (
|
||
<DropdownMenuItem
|
||
key={resolution.name}
|
||
onClick={() => onResolutionChange(resolution)}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Monitor className="h-4 w-4 text-primary" />
|
||
<span className="flex-1">{resolution.name}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{resolution.width}×{resolution.height}
|
||
</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuLabel className="text-xs text-muted-foreground">태블릿</DropdownMenuLabel>
|
||
{SCREEN_RESOLUTIONS.filter((r) => r.category === "tablet").map((resolution) => (
|
||
<DropdownMenuItem
|
||
key={resolution.name}
|
||
onClick={() => onResolutionChange(resolution)}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Tablet className="h-4 w-4 text-green-500" />
|
||
<span className="flex-1">{resolution.name}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{resolution.width}×{resolution.height}
|
||
</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuLabel className="text-xs text-muted-foreground">모바일</DropdownMenuLabel>
|
||
{SCREEN_RESOLUTIONS.filter((r) => r.category === "mobile").map((resolution) => (
|
||
<DropdownMenuItem
|
||
key={resolution.name}
|
||
onClick={() => onResolutionChange(resolution)}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Smartphone className="h-4 w-4 text-purple-500" />
|
||
<span className="flex-1">{resolution.name}</span>
|
||
<span className="text-xs text-muted-foreground">
|
||
{resolution.width}×{resolution.height}
|
||
</span>
|
||
</DropdownMenuItem>
|
||
))}
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuLabel className="text-xs text-muted-foreground">사용자 정의</DropdownMenuLabel>
|
||
<DropdownMenuItem
|
||
onClick={() => {
|
||
setCustomWidth(screenResolution.width.toString());
|
||
setCustomHeight(screenResolution.height.toString());
|
||
setShowCustomInput(true);
|
||
}}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||
<span className="flex-1">사용자 정의...</span>
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
)}
|
||
</DropdownMenu>
|
||
|
||
{/* 사용자 정의 해상도 다이얼로그 */}
|
||
<Dialog open={showCustomInput} onOpenChange={setShowCustomInput}>
|
||
<DialogContent className="sm:max-w-[320px]">
|
||
<DialogHeader>
|
||
<DialogTitle>사용자 정의 해상도</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="grid grid-cols-2 gap-4 py-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="customWidth" className="text-sm">너비 (px)</Label>
|
||
<Input
|
||
id="customWidth"
|
||
type="number"
|
||
value={customWidth}
|
||
onChange={(e) => setCustomWidth(e.target.value)}
|
||
placeholder="1920"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="customHeight" className="text-sm">높이 (px)</Label>
|
||
<Input
|
||
id="customHeight"
|
||
type="number"
|
||
value={customHeight}
|
||
onChange={(e) => setCustomHeight(e.target.value)}
|
||
placeholder="1080"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setShowCustomInput(false)}>
|
||
취소
|
||
</Button>
|
||
<Button onClick={handleCustomResolution}>
|
||
적용
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
)}
|
||
|
||
{/* 격자 도구 그룹 */}
|
||
{gridSettings && onGridSettingsChange && (
|
||
<>
|
||
<div className="h-5 w-px bg-border" />
|
||
<div className="flex items-center gap-2 rounded-md bg-secondary/30 px-3 py-1.5">
|
||
<Grid3X3 className="h-4 w-4 text-muted-foreground" />
|
||
<div className="flex items-center gap-3">
|
||
<label className="flex cursor-pointer items-center gap-1.5">
|
||
{gridSettings.showGrid ? (
|
||
<Eye className="h-3.5 w-3.5 text-primary" />
|
||
) : (
|
||
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
||
)}
|
||
<Checkbox
|
||
checked={gridSettings.showGrid}
|
||
onCheckedChange={(checked) => updateGridSetting("showGrid", checked as boolean)}
|
||
className="h-3.5 w-3.5"
|
||
/>
|
||
<span className="hidden text-xs text-foreground sm:inline">격자</span>
|
||
</label>
|
||
<label className="flex cursor-pointer items-center gap-1.5">
|
||
<Zap className={`h-3.5 w-3.5 ${gridSettings.snapToGrid ? "text-primary" : "text-muted-foreground"}`} />
|
||
<Checkbox
|
||
checked={gridSettings.snapToGrid}
|
||
onCheckedChange={(checked) => updateGridSetting("snapToGrid", checked as boolean)}
|
||
className="h-3.5 w-3.5"
|
||
/>
|
||
<span className="hidden text-xs text-foreground sm:inline">스냅</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* 중앙: 정렬/배분 도구 (다중 선택 시) */}
|
||
{selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && (
|
||
<div className="flex items-center gap-1 rounded-md border bg-accent/50 px-2 py-1">
|
||
{/* 정렬 */}
|
||
{onAlign && (
|
||
<>
|
||
<span className="mr-1 hidden text-xs font-medium text-foreground sm:inline">정렬</span>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("left")} title="좌측 정렬 (Alt+L)">
|
||
<AlignStartVertical className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerX")} title="가로 중앙 (Alt+C)">
|
||
<AlignCenterVertical className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("right")} title="우측 정렬 (Alt+R)">
|
||
<AlignEndVertical className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<div className="mx-0.5 h-4 w-px bg-border" />
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("top")} title="상단 정렬 (Alt+T)">
|
||
<AlignStartHorizontal className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerY")} title="세로 중앙 (Alt+M)">
|
||
<AlignCenterHorizontal className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("bottom")} title="하단 정렬 (Alt+B)">
|
||
<AlignEndHorizontal className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
{/* 배분 (3개 이상) */}
|
||
{onDistribute && selectedCount >= 3 && (
|
||
<>
|
||
<div className="mx-1 h-4 w-px bg-border" />
|
||
<span className="mr-1 hidden text-xs font-medium text-foreground sm:inline">배분</span>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("horizontal")} title="가로 균등 배분 (Alt+H)">
|
||
<AlignHorizontalSpaceAround className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("vertical")} title="세로 균등 배분 (Alt+V)">
|
||
<AlignVerticalSpaceAround className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
{/* 크기 맞추기 */}
|
||
{onMatchSize && (
|
||
<>
|
||
<div className="mx-1 h-4 w-px bg-border" />
|
||
<span className="mr-1 hidden text-xs font-medium text-foreground sm:inline">크기</span>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("width")} title="너비 맞추기 (Alt+W)">
|
||
<RulerIcon className="h-3.5 w-3.5" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("height")} title="높이 맞추기 (Alt+E)">
|
||
<RulerIcon className="h-3.5 w-3.5 rotate-90" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("both")} title="크기 모두 맞추기">
|
||
<Equal className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</>
|
||
)}
|
||
|
||
<div className="mx-1 h-4 w-px bg-border" />
|
||
<span className="text-xs font-medium text-muted-foreground">{selectedCount}개</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 우측: 액션 버튼 그룹 */}
|
||
<div className="flex items-center gap-2">
|
||
{/* 라벨 토글 */}
|
||
{onToggleLabels && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onToggleLabels}
|
||
className="hidden items-center gap-1 sm:flex"
|
||
title="라벨 일괄 표시/숨기기 (Alt+Shift+L)"
|
||
>
|
||
<Tag className="h-4 w-4" />
|
||
<span>라벨</span>
|
||
</Button>
|
||
)}
|
||
|
||
{/* 단축키 도움말 */}
|
||
{onShowShortcuts && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={onShowShortcuts}
|
||
className="h-9 w-9"
|
||
title="단축키 도움말 (?)"
|
||
>
|
||
<Keyboard className="h-4 w-4" />
|
||
</Button>
|
||
)}
|
||
|
||
{onPreview && (
|
||
<Button variant="outline" size="sm" onClick={onPreview} className="hidden items-center gap-2 sm:flex">
|
||
<Eye className="h-4 w-4" />
|
||
<span>미리보기</span>
|
||
</Button>
|
||
)}
|
||
{onGenerateMultilang && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onGenerateMultilang}
|
||
disabled={isGeneratingMultilang}
|
||
className="hidden items-center gap-2 sm:flex"
|
||
title="화면 라벨에 대한 다국어 키를 자동으로 생성합니다"
|
||
>
|
||
<Languages className="h-4 w-4" />
|
||
<span>{isGeneratingMultilang ? "생성 중..." : "다국어"}</span>
|
||
</Button>
|
||
)}
|
||
{onOpenMultilangSettings && (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={onOpenMultilangSettings}
|
||
className="hidden items-center gap-2 sm:flex"
|
||
title="다국어 키 연결 및 설정을 관리합니다"
|
||
>
|
||
<Settings2 className="h-4 w-4" />
|
||
<span>다국어설정</span>
|
||
</Button>
|
||
)}
|
||
{onMigrateToV3 && (
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
onClick={onMigrateToV3}
|
||
disabled={isMigrating}
|
||
className="hidden items-center gap-2 bg-gradient-to-r from-indigo-500 to-purple-600 text-white shadow-sm hover:from-indigo-600 hover:to-purple-700 sm:flex"
|
||
>
|
||
<Zap className="h-4 w-4" />
|
||
<span>{isMigrating ? "변환 중..." : "V3 변환"}</span>
|
||
</Button>
|
||
)}
|
||
<Button onClick={onSave} disabled={isSaving} size="sm" className="flex items-center gap-2">
|
||
<Save className="h-4 w-4" />
|
||
<span className="hidden sm:inline">{isSaving ? "저장 중..." : "저장"}</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SlimToolbar;
|