diff --git a/package-lock.json b/package-lock.json index f5e1c22..ffc6446 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@tailwindcss/vite": "^4.3.3", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-icons": "^5.7.0", "tailwindcss": "^4.3.3" }, "devDependencies": { @@ -2869,6 +2870,15 @@ "react": "^19.2.7" } }, + "node_modules/react-icons": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", diff --git a/package.json b/package.json index e8e8667..9945c97 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tailwindcss/vite": "^4.3.3", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-icons": "^5.7.0", "tailwindcss": "^4.3.3" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 181b5b0..50bcbe7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,59 @@ +import Header from "./components/header"; +import Workspace from "./components/workspace"; +import { useComponent } from "./hooks/useComponent"; +import { useScreen } from "./hooks/useScreen" +import { useTables } from "./hooks/useTables"; + function App() { + const projectData = { + id: "", + title: "", + detail: "", + createdTime: "", + updatedTime: "", + screens: [ + { + id: "screen1", + components: [], + index: 0, + name: "screen1", + projectId: "" + } + ], + tables: [] + } + + const { + screenData, + screenActions, + screenDispatch + } = useScreen(projectData); + + const { + componentData, + componentActions, + componentDispatch + } = useComponent(screenData.curScreenIndex, screenData.curScreen, screenDispatch.setScreens); + + const { + tableData, + tableActions + } = useTables(projectData); + return ( - <> - +
+
+
+ +
+
) } diff --git a/src/components/editor/canvas.tsx b/src/components/editor/canvas.tsx new file mode 100644 index 0000000..790cc45 --- /dev/null +++ b/src/components/editor/canvas.tsx @@ -0,0 +1,320 @@ +import type { useComponentData } from "../../hooks/useComponent"; +import { useDragComponent } from "../../hooks/useDragComponent"; +import { ComponentType } from "../../types/component"; +import type { ButtonGeneralData, Component, TableGeneralData, TextFieldGeneralData, TextGeneralData, UIData } from "../../types/component"; +import { useEffect, useRef, useState } from "react"; + + +interface CanvasComponentProps { + component: Component, + width: number, + height: number +} + +function CanvasComponent({ component, width, height }: CanvasComponentProps) { + + const baseStyle = { + width: width, + height: height + } + + switch (component.type) { + case ComponentType.Button: + return ( + + ) + case ComponentType.Text: + return ( +
+ {(component.generalData as TextGeneralData).text} +
+ ) + case ComponentType.TextField: + return ( + + ) + case ComponentType.Table: + return ( +
+
+ {(component.generalData as TableGeneralData).columns.map((col, idx) => +
+ {col} +
+ )} +
+
+ {(component.generalData as TableGeneralData).columns.map((_, idx) => +
+
+ )} +
+
+ {(component.generalData as TableGeneralData).columns.map((_, idx) => +
+
+ )} +
+
+ {(component.generalData as TableGeneralData).columns.map((_, idx) => +
+
+ )} +
+
+ ) + } + +} + +interface CanvasProps { + componentData: useComponentData + canvasWidth: number + moveComponent: (index: number, uiData: UIData) => void + selectComponent: (index: number) => void + resetSelectingComponent: () => void +} + +export default function Canvas({ + componentData, + canvasWidth, + moveComponent, + selectComponent: selectComponentGrobal, + resetSelectingComponent +}: CanvasProps) { + + const outRef = useRef(null); + const canvasRef = useRef(null); + const [widgetScale] = useState(0.4); + const [scale, setScale] = useState(1); + const { + componentX, + componentY, + componentW, + componentH, + onDragStart, + draggingComponent, + draggingSquare, + onDragEnd + } = useDragComponent( + componentData.selectingComponent, + componentData.selectingComponentIndex, + moveComponent, + scale, + widgetScale + ); + + const borderWidth = 1; + const padding = 4; + + useEffect(() => { + if (!outRef.current) return; + + const observer = new ResizeObserver((entries) => { + const width = entries[0].contentRect.width; + const newScale = width / canvasWidth; + setScale(newScale); + }) + + observer.observe(outRef.current); + return () => observer.disconnect(); + }, [canvasWidth]); + + const componentStyle = (isSelecting: boolean, component: Component) => { + if (isSelecting) return { + position: "absolute" as const, + left: `${componentX - padding * 2 - borderWidth}px`, + top: `${componentY - padding * 2 - borderWidth}px`, + width: `${componentW + padding * 4 + borderWidth * 2}px`, + height: `${componentH + padding * 4 + borderWidth * 2}px`, + } + + return { + position: "absolute" as const, + left: `${component.uiData.posX}px`, + top: `${component.uiData.posY}px`, + width: `${component.uiData.width}px`, + height: `${component.uiData.height}px`, + } + } + + const squareStyle = (index: number) => { + const squareWidth = padding * 2 + borderWidth; + if (index == 0) { + return { + position: "absolute" as const, + left: "0px", + top: "0px", + width: `${squareWidth}px`, + height: `${squareWidth}px`, + + } + } else if (index == 1) { + return { + position: "absolute" as const, + left: `${squareWidth + componentW}px`, + top: "0px", + width: `${squareWidth}px`, + height: `${squareWidth}px`, + } + } else if (index == 2) { + return { + position: "absolute" as const, + left: `${squareWidth + componentW}px`, + top: `${squareWidth + componentH}px`, + width: `${squareWidth}px`, + height: `${squareWidth}px`, + } + } else { + return { + position: "absolute" as const, + left: "0px", + top: `${squareWidth + componentH}px`, + width: `${squareWidth}px`, + height: `${squareWidth}px`, + } + } + } + + const borderStyle = (index: number) => { + const squareWidth = padding * 2 + borderWidth; + if (index == 0) { + return { + position: "absolute" as const, + left: `${squareWidth}px`, + top: `${padding}px`, + width: `${componentW}px`, + height: `${borderWidth}px`, + } + } else if (index == 1) { + return { + position: "absolute" as const, + left: `${squareWidth + componentW + padding}px`, + top: `${squareWidth}px`, + width: `${borderWidth}px`, + height: `${componentH}px`, + } + } else if (index == 2) { + return { + position: "absolute" as const, + left: `${squareWidth}px`, + top: `${squareWidth + componentH + padding}px`, + width: `${componentW}px`, + height: `${borderWidth}px`, + } + } else { + return { + position: "absolute" as const, + left: `${padding}px`, + top: `${squareWidth}px`, + width: `${borderWidth}px`, + height: `${componentH}px`, + } + } + } + + const selectComopnent = (e: React.MouseEvent, index: number) => { + e.stopPropagation(); + selectComponentGrobal(index); + } + + const resetSelectComponent = (e: React.MouseEvent) => { + e.stopPropagation(); + resetSelectingComponent(); + } + + return ( +
+
+ {/* Canvas Content */} +
+ {componentData.curScreenComponents.map((component, index) => +
{ selectComopnent(e, index) }} + draggable + onDragStart={(e) => { if (componentData.selectingComponentIndex === index) onDragStart(e) }} + onDrag={(e) => { if (componentData.selectingComponentIndex === index) draggingComponent(e) }} + onDragEnd={(e) => { if (componentData.selectingComponentIndex === index) onDragEnd(e) }} + > + {componentData.selectingComponentIndex === index ? +
+
+ +
+ {[0, 1, 2, 3].map((num) => ( +
{ draggingSquare(e, num) }} + onDragEnd={onDragEnd} + className="bg-black" + /> + ))} + {[0, 1, 2, 3].map((num) => ( +
+ ))} +
: +
+ +
+ } +
+ )} +
+ + {/* Grid overlay (optional, for development) */} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx new file mode 100644 index 0000000..149996f --- /dev/null +++ b/src/components/editor/editor.tsx @@ -0,0 +1,35 @@ +import type { useComponentActions, useComponentData } from "../../hooks/useComponent"; +import Canvas from "./canvas"; +import { useEffect, useRef, useState } from "react"; + +interface EditorProps { + componentData: useComponentData, + componentAcionts: useComponentActions, +} + +export default function Editor({ + componentData, + componentAcionts, +}: EditorProps) { + + + const editorRef = useRef(null); + const [width, setWidth] = useState(600); + + useEffect(() => { + if (!editorRef.current) return; + setWidth(editorRef.current.offsetWidth); + }, [editorRef]) + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 0000000..9f2080c --- /dev/null +++ b/src/components/header.tsx @@ -0,0 +1,84 @@ +import { jsonExport } from '../lib/jsonExport'; +import type { Project } from '../types/project'; +import { useState } from 'react'; +import { FaFileExport } from 'react-icons/fa'; + +export default function Header({ projectData }: { projectData: Project }) { + + const [exportTypeOpen, setExportTypeOpen] = useState(false); + + const exportJson = async () => { + // console.log(pid) + // const project = await fetch(`/api/projects/${pid}`); + // const data = await project.json(); + jsonExport(projectData); + } + + return ( +
+
+
+ {/* */} +
+ {projectData.title} +
+
+
+
+ + {exportTypeOpen && +
+
    +
  • + JSONファイル +
  • + {/*
  • + DTRAM modelファイル +
  • */} +
+
+ } +
+ {/* */} +
+
+
+ {exportTypeOpen && +
setExportTypeOpen(false)} + /> + } +
+
+ ); +} \ No newline at end of file diff --git a/src/components/property/buttonAction.tsx b/src/components/property/buttonAction.tsx new file mode 100644 index 0000000..8300b1d --- /dev/null +++ b/src/components/property/buttonAction.tsx @@ -0,0 +1,1016 @@ +import type{ SelectComponentListenerType } from "../../hooks/useComponent"; +import { ButtonActionTypeMap } from "../../types/action"; +import type { ButtonActionType, ButtonAddDataAction, ButtonDeleteDataAction, ButtonNavigateAction, ButtonSearchDataAction, ButtonUpdateDataAction } from "../../types/action"; +import type { ButtonComponent, Component } from "../../types/component"; +import type { Screen } from "../../types/screen"; +import type { Table } from "../../types/table"; +import { useEffect, useMemo, useState } from "react" +import { MdEdit } from "react-icons/md"; + +interface ButtonActionPropertiesProps { + component: ButtonComponent + screens: Screen[], + curScreenIndex: number, + tables: Table[], + updateComponent: (index: number, newComponent: Component) => void, + setSelectComponentListener: (listener: SelectComponentListenerType) => void + activateSelectMode: () => void +} + +export function ButtonActionProperties({ + component, + screens, + curScreenIndex, + tables, + updateComponent, + setSelectComponentListener, + activateSelectMode +}: ButtonActionPropertiesProps) { + + const buttonAction = useMemo(() => component.action?.type ?? "", [component]); + const [action, setAction] = useState(component.action?.type ?? ""); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setAction(buttonAction) + }, [buttonAction]) + + return ( +
+
+ + +
+ {action === "navigate" && } + {action === "addData" && } + {action === "updateData" && } + {action === "deleteData" && } + {action === "searchData" && } +
+ ) +} + +interface NavigatePropertiesProps { + component: ButtonComponent + screens: Screen[], + curScreenIndex: number, + updateComponent: (index: number, component: Component) => void +} + +function NavigateProperties({ + screens, + curScreenIndex, + component, + updateComponent, +}: NavigatePropertiesProps) { + + const originalScreenName = useMemo(() => (component.action?.action as ButtonNavigateAction)?.nextScreenName ?? "", [component]) + const [screenName, setScreenName] = useState(originalScreenName); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setScreenName(originalScreenName) + }, [originalScreenName]) + + const validate = () => { + if (!screenName.trim()) { + return false; + } + if (screenName === "") { + return false; + } + return true; + } + + const handleConfirm = () => { + const navigateAction: ButtonNavigateAction = { + nextScreenName: screenName + } + const newComponent: ButtonComponent = { ...component, action: { type: "navigate", action: navigateAction } } + updateComponent(-1, newComponent); + } + + return ( +
+
+ + +
+
+ {validate() && + + } +
+
+ ) +} + +interface AddDataPropertiesProps { + component: ButtonComponent + screens: Screen[] + tables: Table[] + updateComponent: (index: number, newComponent: Component) => void, + setSelectComponentListener: (listener: SelectComponentListenerType) => void + activateSelectMode: () => void +} + +function AddDataProperties({ + component, + screens, + tables, + updateComponent, + setSelectComponentListener, + activateSelectMode +}: AddDataPropertiesProps) { + + const buttonAction = useMemo(() => (component.action?.type ?? "") === "addData" ? component.action?.action as ButtonAddDataAction + : { + tableName: "", + newData: {}, + failScreenName: "", + successScreenName: "" + }, [component]) + + const [tableName, setTableName] = useState(buttonAction?.tableName ?? ""); + const [data, setData] = useState<{ [key: string]: string }>(buttonAction?.newData ?? {}); + const [selectModeButton, setSelectModeButton] = useState(""); + const [successScreen, setSuccessScreen] = useState(buttonAction?.successScreenName ?? "") + const [failScreen, setFailScreen] = useState(buttonAction?.failScreenName ?? "") + + const handleTableChange = (e: React.ChangeEvent) => { + setTableName(e.target.value); + const res: { [key: string]: string } = {} + for (const col of tables.find((table) => table.name === e.target.value)?.columns ?? []) { + if (!col.foreignTableName) { + res[col.name] = ""; + } else { + const ftable = tables.find((table) => table.name === col.foreignTableName); + for (const fcol of ftable?.columns ?? []) { + if (fcol.isPrimary) { + res[`${ftable?.name}.${fcol.name}`] = ""; + } + } + } + } + setData(res); + setSuccessScreen(""); + setFailScreen(""); + } + + const handleChange = (key: string, value: string) => { + setData(prev => ({ ...prev, [key]: value })); + } + + const handleSelectMode = (key: string) => { + setSelectComponentListener((component) => { + if (component !== null) { + setData(prev => ({ ...prev, [key]: "${" + component.name + "}" })); + } + setSelectModeButton(""); + }) + setSelectModeButton(key); + activateSelectMode(); + } + + const validate = () => { + if (tableName === "") { + return false; + } + for (const key of Object.keys(data)) { + if (data[key] === "") { + return false; + } + } + return true; + } + + const handleConfirm = () => { + const action: ButtonAddDataAction = { + tableName: tableName, + newData: data, + successScreenName: successScreen, + failScreenName: failScreen + } + const newComponent: ButtonComponent = { ...component, action: { type: "addData", action: action } }; + updateComponent(-1, newComponent) + } + + const changeCheck = () => { + if (!buttonAction) { + return false; + } + if (buttonAction.tableName !== tableName) { + return false; + } + for (const key of Object.keys(buttonAction.newData)) { + if (!(key in data)) { + return false; + } + if (data[key] !== buttonAction.newData[key]) { + return false; + } + } + if (buttonAction.successScreenName !== successScreen) { + return false; + } + if (buttonAction.failScreenName !== failScreen) { + return false; + } + return true; + } + + return ( +
+
+ + +
+ {tableName !== "" &&
+ {Object.keys(data).map((colName, idx) => ( +
+ +
+ handleChange(colName, e.target.value)} + className="flex-6 w-full px-3 py-1 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> + +
+
+ ))} +
+ + +
+
+ + +
+
} +
+ +
+
+ ) +} + + + +interface UpdateDataPropertiesProps { + component: ButtonComponent + screens: Screen[] + tables: Table[] + updateComponent: (index: number, newComponent: Component) => void, + setSelectComponentListener: (listener: SelectComponentListenerType) => void + activateSelectMode: () => void +} + +function UpdateDataProperties({ + component, + screens, + tables, + updateComponent, + setSelectComponentListener, + activateSelectMode +}: UpdateDataPropertiesProps) { + + const buttonAction = useMemo(() => (component.action?.type ?? "") === "updateData" ? component.action?.action as ButtonUpdateDataAction + : { + tableName: "", + newData: {}, + failScreenName: "", + successScreenName: "" + }, [component]) + + const [tableName, setTableName] = useState(buttonAction?.tableName ?? ""); + const [data, setData] = useState<{ [key: string]: string }>(buttonAction?.newData ?? {}); + const [selectModeButton, setSelectModeButton] = useState(""); + const [successScreen, setSuccessScreen] = useState(buttonAction?.successScreenName ?? "") + const [failScreen, setFailScreen] = useState(buttonAction?.failScreenName ?? "") + + const handleTableChange = (e: React.ChangeEvent) => { + setTableName(e.target.value); + const res: { [key: string]: string } = {} + for (const col of tables.find((table) => table.name === e.target.value)?.columns ?? []) { + if (!col.foreignTableName) { + res[col.name] = ""; + } else { + const ftable = tables.find((table) => table.name === col.foreignTableName); + for (const fcol of ftable?.columns ?? []) { + if (fcol.isPrimary) { + res[`${ftable?.name}.${fcol.name}`] = ""; + } + } + } + } + setData(res); + setSuccessScreen(""); + setFailScreen(""); + } + + const handleChange = (key: string, value: string) => { + setData(prev => ({ ...prev, [key]: value })); + } + + const handleSelectMode = (key: string) => { + setSelectComponentListener((component) => { + if (component !== null) { + setData(prev => ({ ...prev, [key]: "${" + component.name + "}" })); + } + setSelectModeButton(""); + }) + setSelectModeButton(key); + activateSelectMode(); + } + + const validate = () => { + if (tableName === "") { + return false; + } + for (const key of Object.keys(data)) { + if (data[key] === "") { + return false; + } + } + return true; + } + + const handleConfirm = () => { + const action: ButtonUpdateDataAction = { + tableName: tableName, + newData: data, + successScreenName: successScreen, + failScreenName: failScreen + } + const newComponent: ButtonComponent = { ...component, action: { type: "updateData", action: action } }; + updateComponent(-1, newComponent) + } + + const changeCheck = () => { + if (!buttonAction) { + return false; + } + if (buttonAction.tableName !== tableName) { + return false; + } + for (const key of Object.keys(buttonAction.newData)) { + if (!(key in data)) { + return false; + } + if (data[key] !== buttonAction.newData[key]) { + return false; + } + } + if (buttonAction.successScreenName !== successScreen) { + return false; + } + if (buttonAction.failScreenName !== failScreen) { + return false; + } + return true; + } + + return ( +
+
+ + +
+ {tableName !== "" &&
+ {Object.keys(data).map((colName, idx) => ( +
+ +
+ handleChange(colName, e.target.value)} + className="flex-6 w-full px-3 py-1 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> + +
+
+ ))} +
+ + +
+
+ + +
+
} +
+ +
+
+ ) +} + +interface DeleteDataPropertiesProps { + component: ButtonComponent + screens: Screen[] + tables: Table[] + updateComponent: (index: number, newComponent: Component) => void, + setSelectComponentListener: (listener: SelectComponentListenerType) => void + activateSelectMode: () => void +} + +function DeleteDataProperties({ + component, + screens, + tables, + updateComponent, + setSelectComponentListener, + activateSelectMode +}: DeleteDataPropertiesProps) { + + const buttonAction = useMemo(() => (component.action?.type ?? "") === "deleteData" ? component.action?.action as ButtonDeleteDataAction : { + tableName: "", + deleteData: {}, + failScreenName: "", + successScreenName: "" + }, [component]) + + const [tableName, setTableName] = useState(buttonAction?.tableName ?? ""); + const [data, setData] = useState<{ [key: string]: string }>(buttonAction?.deleteData ?? {}); + const [selectModeButton, setSelectModeButton] = useState(""); + const [successScreen, setSuccessScreen] = useState(buttonAction?.successScreenName ?? "") + const [failScreen, setFailScreen] = useState(buttonAction?.failScreenName ?? "") + + const handleTableChange = (e: React.ChangeEvent) => { + setTableName(e.target.value); + const res: { [key: string]: string } = {} + for (const col of tables.find((table) => table.name === e.target.value)?.columns ?? []) { + if (col.isPrimary) { + res[col.name] = ""; + } + } + setData(res); + setSuccessScreen(""); + setFailScreen(""); + } + + const handleChange = (key: string, value: string) => { + setData(prev => ({ ...prev, [key]: value })); + } + + const handleSelectMode = (key: string) => { + setSelectComponentListener((component) => { + if (component !== null) { + setData(prev => ({ ...prev, [key]: "${" + component.name + "}" })); + } + setSelectModeButton(""); + }) + setSelectModeButton(key); + activateSelectMode(); + } + + const validate = () => { + if (tableName === "") { + return false; + } + for (const key of Object.keys(data)) { + if (data[key] === "") { + return false; + } + } + return true; + } + + const handleConfirm = () => { + const action: ButtonDeleteDataAction = { + tableName: tableName, + deleteData: data, + successScreenName: successScreen, + failScreenName: failScreen + } + const newComponent: ButtonComponent = { ...component, action: { type: "deleteData", action: action } }; + updateComponent(-1, newComponent) + } + + const changeCheck = () => { + if (!buttonAction) { + return false; + } + if (buttonAction.tableName !== tableName) { + return false; + } + for (const key of Object.keys(buttonAction.deleteData)) { + if (!(key in data)) { + return false; + } + if (data[key] !== buttonAction.deleteData[key]) { + return false; + } + } + if (buttonAction.successScreenName !== successScreen) { + return false; + } + if (buttonAction.failScreenName !== failScreen) { + return false; + } + return true; + } + + return ( +
+
+ + +
+ {tableName !== "" &&
+ {Object.keys(data).map((colName, idx) => ( +
+ +
+ handleChange(colName, e.target.value)} + className="flex-6 w-full px-3 py-1 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> + +
+
+ ))} +
+ + +
+
+ + +
+
} +
+ +
+
+ ) +} + + +interface SearchDataPropertiesProps { + component: ButtonComponent + tables: Table[] + updateComponent: (index: number, newComponent: Component) => void, + setSelectComponentListener: (listener: SelectComponentListenerType) => void + activateSelectMode: () => void +} + +function SearchDataProperties({ + component, + tables, + updateComponent, + setSelectComponentListener, + activateSelectMode +}: SearchDataPropertiesProps) { + + const buttonAction = useMemo(() => (component.action?.type ?? "") === "searchData" ? component.action?.action as ButtonSearchDataAction : { + tableName: "", + searchData: {}, + targetComponentName: "" + } as ButtonSearchDataAction, [component]) + + const [tableName, setTableName] = useState(buttonAction?.tableName ?? ""); + const [selectableColumns, setSelectableColumns] = useState(Object.keys(buttonAction?.searchData ?? {})); + const [data, setData] = useState<{ [key: string]: string }>(buttonAction?.searchData ?? {}); + const [selectModeButton, setSelectModeButton] = useState(""); + const [targetComponentId, setTargetComponentId] = useState(buttonAction.targetComponentName ?? ""); + + const handleTableChange = (e: React.ChangeEvent) => { + setTableName(e.target.value); + const res = []; + for (const col of tables.find((table) => table.name === e.target.value)?.columns ?? []) { + if (!col.isPrimary) { + if (!col.foreignTableName) { + res.push(col.name) + } else { + const table = tables.find((table) => table.name === col.foreignTableName); + for (const fcol of col.foreignTableColumns ?? []) { + res.push(`${table?.name}.${fcol}`) + } + } + } + } + console.log(res); + setSelectableColumns(res); + setData({}); + } + + const handleChange = (key: string, value: string) => { + setData(prev => ({ ...prev, [key]: value })); + } + + const handleSelectMode = (key: string) => { + setSelectComponentListener((component) => { + if (component !== null) { + setData(prev => ({ ...prev, [key]: "${" + component.name + "}" })); + } + setSelectModeButton(""); + }) + setSelectModeButton(key); + activateSelectMode(); + } + + const handleTargetSelect = () => { + setSelectComponentListener((component) => { + if (component !== null) { + setTargetComponentId("${" + component.name + "}") + } + setSelectModeButton(""); + }) + setSelectModeButton("_____"); + activateSelectMode(); + } + + const handleSelect = (colName: string) => { + if (colName in data) { + setData(prev => { + const res: { [key: string]: string } = {}; + for (const key of Object.keys(prev)) { + if (key !== colName) { + res[key] = prev[key]; + } + } + return res; + }); + } else { + setData(prev => ({ ...prev, [colName]: "" })); + } + } + + const validate = () => { + if (tableName === "") { + return false; + } + if (Object.keys(data).length === 0) { + return false; + } + for (const key of Object.keys(data)) { + if (data[key] === "") { + return false; + } + } + if (!targetComponentId.trim()) { + return false; + } + if (targetComponentId === "") { + return false; + } + return true; + } + + const handleConfirm = () => { + const action: ButtonSearchDataAction = { + tableName: tableName, + searchData: data, + targetComponentName: targetComponentId + } + const newComponent: ButtonComponent = { ...component, action: { type: "searchData", action: action } }; + updateComponent(-1, newComponent) + } + + const changeCheck = () => { + if (!buttonAction) { + return false; + } + if (buttonAction.tableName !== tableName) { + return false; + } + for (const key of Object.keys(buttonAction.searchData)) { + if (!(key in data)) { + return false; + } + if (data[key] !== buttonAction.searchData[key]) { + return false; + } + } + if (buttonAction.targetComponentName !== targetComponentId) { + return false; + } + return true; + } + + return ( +
+
+ + +
+ {tableName !== "" &&
+ {selectableColumns.map((colName, idx) => ( +
+ +
+ handleChange(colName, e.target.value)} + readOnly={!(colName in data)} + className="flex-6 w-full px-3 py-1 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> + +
+
+ ))} +
+ +
+ setTargetComponentId(e.target.value)} + className="flex-6 w-full px-3 py-1 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> + +
+
+
} +
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/components/property/buttonGeneral.tsx b/src/components/property/buttonGeneral.tsx new file mode 100644 index 0000000..0c9fda0 --- /dev/null +++ b/src/components/property/buttonGeneral.tsx @@ -0,0 +1,50 @@ +import type { ButtonGeneralData, Component } from "../../types/component"; +import { useEffect, useMemo, useState } from "react"; + + +interface ButtonGeneralPropertiesProps { + component: Component + index: number + updateComponent: (index: number, component: Component) => void +} + +export function ButtonGeneralProperties({ + component, + index, + updateComponent, +}: ButtonGeneralPropertiesProps) { + + const generalData = useMemo(() => component.generalData as ButtonGeneralData, [component]) + const [text, setText] = useState(generalData.text); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setText(generalData.text); + }, [generalData]) + + + const handleChange = (e: React.ChangeEvent) => { + setText(e.target.value); + updateComponent(index, { ...component, type: "button", generalData: { ...component.generalData, text: e.target.value } }) + } + + return ( +
+ + handleChange(e)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + ; (e.currentTarget as HTMLInputElement).blur() + } + }} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> +
+ ) +} \ No newline at end of file diff --git a/src/components/property/buttonProperties.tsx b/src/components/property/buttonProperties.tsx new file mode 100644 index 0000000..732aa7f --- /dev/null +++ b/src/components/property/buttonProperties.tsx @@ -0,0 +1,52 @@ +import { ActiveTab } from "../../constants/activeTab" +import type { ButtonComponent } from "../../types/component" +import { ButtonGeneralProperties } from "./buttonGeneral" +import { ButtonActionProperties } from "./buttonAction" +import type { Screen } from "../../types/screen" +import type { Table } from "../../types/table" +import type { useComponentActions } from "../../hooks/useComponent" + + + +interface ButtonPropertyProps { + component: ButtonComponent + index: number + activeTab: number + screens: Screen[] + curScreenIndex: number, + tables: Table[], + componentActions: useComponentActions +} + +export function ButtonProperty({ + component, + index, + activeTab, + screens, + curScreenIndex, + tables, + componentActions +}: ButtonPropertyProps) { + + if (activeTab === ActiveTab.GENERAL) + return + + if (activeTab === ActiveTab.STYLE) + return
+ + if (activeTab === ActiveTab.ACTION) + return + +} \ No newline at end of file diff --git a/src/components/property/property.tsx b/src/components/property/property.tsx new file mode 100644 index 0000000..b3bc785 --- /dev/null +++ b/src/components/property/property.tsx @@ -0,0 +1,161 @@ +import type { useComponentActions, useComponentData } from "../../hooks/useComponent"; +import { ComponentType } from "../../types/component"; +import type { Component } from "../../types/component"; +import { useEffect, useMemo, useState } from "react"; +import { TextProperty } from "./textProperties"; +import { ButtonProperty } from "./buttonProperties"; +import type { Table } from "../../types/table"; +import { TableProperty } from "./tableProperties"; +import type { Screen } from "../../types/screen"; +import { ActiveTab } from "../../constants/activeTab"; + +interface PropertyProps { + componentData: useComponentData, + tables: Table[] + screens: Screen[], + curScreenIndex: number, + componentActions: useComponentActions, +} + + +export default function Property({ + componentData, + tables, + screens, + curScreenIndex, + componentActions +}: PropertyProps) { + + const { selectingComponent, selectingComponentIndex } = componentData + + if (selectingComponent === null) + return
+ + return ; +} + +interface ComponentPropertyAreaProps { + component: Component + index: number + tables: Table[] + screens: Screen[] + curScreenIndex: number + componentActions: useComponentActions, +} + +function ComponentPropertyArea({ + component, + index, + tables, + screens, + curScreenIndex, + componentActions +}: ComponentPropertyAreaProps) { + const componentName = useMemo(() => component.name, [component]) + const [name, setName] = useState(componentName); + const [activeTab, setActiveTab] = useState(ActiveTab.GENERAL); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setName(componentName); + }, [componentName]) + + const handleNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); + } + + function nameValidate() { + if (!name.trim()) return false; + return true; + } + + const handleNameConfirm = (e: React.FocusEvent) => { + if (!nameValidate()) { + setName(component.name); + return; + } + componentActions.updateComponent(index, { ...component, name: e.target.value }) + // componentActions.updateConfirm({ ...component, name: e.target.value }); + } + + return ( +
+ +
+ {activeTab === 0 &&
+ + handleNameChange(e)} + onBlur={(e) => handleNameConfirm(e)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + ; (e.currentTarget as HTMLInputElement).blur() + } + }} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> +
} + {component.type === ComponentType.Text && + + } + {component.type === ComponentType.Button && + + } + {component.type === ComponentType.Table && + + } +
+
+ ) +} \ No newline at end of file diff --git a/src/components/property/tableProperties.tsx b/src/components/property/tableProperties.tsx new file mode 100644 index 0000000..4b2f21c --- /dev/null +++ b/src/components/property/tableProperties.tsx @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from "react" +import { ActiveTab } from "../../constants/activeTab" +import type { Component, TableComponent, TableGeneralData } from "../../types/component" +import type { Table } from "../../types/table" + + + +interface TablePropertyProps { + component: TableComponent + index: number + activeTab: number + tables: Table[] + updateComponent: (index: number, component: Component) => void +} + +export function TableProperty({ + component, + index, + activeTab, + tables, + updateComponent, +}: TablePropertyProps) { + + const generalData = useMemo(() => component.generalData as TableGeneralData, [component]) + const [reference, setReference] = useState(generalData.referenceTableName); + const selectableColumns = useMemo(() => tables.find(table => table.name === reference)?.columns ?? [], [tables, reference]) + const [columns, setColumns] = useState(generalData.columns); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setReference(generalData.referenceTableName); + setColumns(generalData.columns); + }, [generalData]) + + const onReferenceChange = (e: React.ChangeEvent) => { + setReference(e.target.value); + setColumns([]); + updateComponent(index, { ...component, generalData: { ...generalData, referenceTableName: e.target.value, columns: [] } }) + } + + const onColumnSelect = (name: string) => { + if (columns.includes(name)) { + setColumns(prev => prev.filter(col => col !== name)); + updateComponent(index, { ...component, generalData: { ...generalData, columns: columns.filter(col => col !== name) } }) + } else { + setColumns(prev => [...prev, name]); + updateComponent(index, { ...component, generalData: { ...generalData, columns: [...columns, name] } }) + } + + } + + + if (activeTab === ActiveTab.GENERAL) + return ( +
+
+ + +
+
+ +
+ {selectableColumns.map((col, idx) => { + if (!col.foreignTableColumns) return ( + + ) + return col.foreignTableColumns.map((fcol, idx) => + + ) + })} +
+
+
+ ) + + if (activeTab === ActiveTab.STYLE) + return
+ + if (activeTab === ActiveTab.ACTION) + return
+ +} \ No newline at end of file diff --git a/src/components/property/textProperties.tsx b/src/components/property/textProperties.tsx new file mode 100644 index 0000000..5480a12 --- /dev/null +++ b/src/components/property/textProperties.tsx @@ -0,0 +1,85 @@ +import { useEffect, useMemo, useState } from "react" +import { ActiveTab } from "../../constants/activeTab" +import type { Component, TextComponent, TextGeneralData } from "../../types/component" +import { MdEdit } from "react-icons/md" +import type { useComponentActions } from "../../hooks/useComponent" + +interface TextPropertyProps { + component: TextComponent + index: number + activeTab: ActiveTab + componentActions: useComponentActions + updateComponent: (index: number, component: Component) => void +} + +export function TextProperty({ + component, + index, + activeTab, + componentActions, + updateComponent, +}: TextPropertyProps) { + + const generalData = useMemo(() => component.generalData as TextGeneralData, [component]) + const [text, setText] = useState(generalData.text); + const [selectMode, setSelectMode] = useState(false); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setText(generalData.text); + }, [generalData]) + + + if (activeTab !== ActiveTab.GENERAL) return
+ + const handleChange = (e: React.ChangeEvent) => { + setText(e.target.value); + const newData: TextGeneralData = { ...component.generalData, text: e.target.value } + const newComponent: TextComponent = { ...component, generalData: newData } + updateComponent(index, newComponent) + } + + const handleSelectMode = () => { + componentActions.setSelectComponentListener((comp) => { + if (comp !== null) { + setText("${" + comp.name + "}"); + const newData: TextGeneralData = { ...component.generalData, text: "${" + comp.name + "}" } + const newComponent: TextComponent = { ...component, generalData: newData } + updateComponent(index, newComponent) + } + setSelectMode(false); + }) + setSelectMode(true); + componentActions.activateSelectMode(); + } + + return ( +
+ +
+ handleChange(e)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + (e.currentTarget as HTMLInputElement).blur() + } + }} + className="flex-6 w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + /> + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/sidebar/addComponentMenu.tsx b/src/components/sidebar/addComponentMenu.tsx new file mode 100644 index 0000000..54ad4e4 --- /dev/null +++ b/src/components/sidebar/addComponentMenu.tsx @@ -0,0 +1,76 @@ +import { ComponentType } from "../../types/component"; +import { FiEdit3, FiGrid, FiSquare, FiType } from "react-icons/fi"; + +interface AddComponentMenuProps { + addComponent: (type: ComponentType) => void +} + +export function AddComponentMenu({addComponent}: AddComponentMenuProps) { + return ( +
+
+

コンポーネントを追加

+

画面に追加するコンポーネントを選択してください

+
+ +
+ + + + + + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/sidebar/componentList.tsx b/src/components/sidebar/componentList.tsx new file mode 100644 index 0000000..1cfed9c --- /dev/null +++ b/src/components/sidebar/componentList.tsx @@ -0,0 +1,123 @@ +import { ComponentType } from "../../types/component"; +import type { Component } from "../../types/component" +import { FiEdit3, FiGrid, FiSquare, FiType } from "react-icons/fi"; +import { FaTrash } from "react-icons/fa"; + +function IconComponent({ type, size }: { type: ComponentType, size: number }) { + switch (type) { + case ComponentType.Button: + return ; + case ComponentType.Text: + return ; + case ComponentType.TextField: + return ; + case ComponentType.Table: + return ; + default: + return ; + } +} + +interface ComponentItemProps { + component: Component + isSelecting: boolean + index: number + selectComponent: (index: number) => void + deleteComponent: (index: number, compoenntId: string) => void +} + +function ComponentItem({ component, isSelecting, index, selectComponent, deleteComponent }: ComponentItemProps) { + + const onClickHandler = (e: React.MouseEvent) => { + e.stopPropagation(); + selectComponent(index); + } + + const deleteHandler = (e: React.MouseEvent) => { + e.stopPropagation() + selectComponent(-1); + deleteComponent(index, component.id); + } + + return ( +
+
+ +
+
+
+ {component.type} +
+
+ {component.name.length < 16 ? component.name : `${component.name.slice(0, 16)}...`} +
+
+
+ +
+
+ ) +} + +interface ComponentListProps { + components: Component[], + selectingComponentIndex: number, + selectComponent: (index: number) => void + resetSelectingComponent: () => void + deleteComponent: (componentIndex: number, compoenntId: string) => void +} + +export function ComponentList({ + components, + selectingComponentIndex, + selectComponent, + deleteComponent +}: ComponentListProps) { + + return ( +
+ +
+
+

コンポーネント

+
+ {components.length} +
+
+
+ +
+
+ {components.length === 0 ? ( +
+
+ +
+

コンポーネントなし

+
+ ) : ( +
+ {components.map((item, index) => ( + + ))} +
+ )} +
+
+ +
+ ) +} \ No newline at end of file diff --git a/src/components/sidebar/screenList.tsx b/src/components/sidebar/screenList.tsx new file mode 100644 index 0000000..63bdb9d --- /dev/null +++ b/src/components/sidebar/screenList.tsx @@ -0,0 +1,536 @@ +import type { useComponentActions } from "../../hooks/useComponent"; +import type { useScreenActions, useScreenData } from "../../hooks/useScreen"; +import type { useTablesData } from "../../hooks/useTables"; +import { capitalizeFirst } from "../../lib/stringUtils"; +import { ComponentType } from "../../types/component" +import type { ButtonComponent, Component, TextComponent, TextFieldComponent } from "../../types/component"; +import type { Screen } from "../../types/screen" +import { useMemo, useState } from "react"; +import { FiEdit3, FiMoreHorizontal, FiTrash2, FiX } from "react-icons/fi" + +interface ScreenListPorps { + screenData: useScreenData, + screenActions: useScreenActions, + componentActions: useComponentActions, + tableData: useTablesData + selectComponent: (index: number) => void +} + +export function ScreenList({ screenData, screenActions, componentActions, tableData, selectComponent }: ScreenListPorps) { + + const [showAddModal, setShowAddModel] = useState(false); + const [editIndex, setEditIndex] = useState(-1); + const [editName, setEditName] = useState(""); + const [activeDropdown, setActiveDropdown] = useState(-1); + const [showTemplateModal, setShowTemplateModal] = useState(false); + + const handleShowAddModal = () => { + setEditIndex(-1) + setEditName(""); + setShowAddModel(true); + } + + const closeDropdown = () => { + setActiveDropdown(-1); + } + + const startEdit = (index: number, curName: string) => { + setEditName(curName); + setEditIndex(index); + setShowAddModel(true); + } + + + return ( +
+
+

スクリーン

+
+ +
+ {screenData.screens.map((screen, index) => ( + + ))} +
+ +
+ + +
+ {/* Click outside to close dropdown */} + {activeDropdown !== -1 && ( +
setActiveDropdown(-1)} + /> + )} + {showAddModal && setShowAddModel(false)} + addScreen={screenActions.addScreen} + editIndex={editIndex} + editName={editName} + selectComponent={selectComponent} + updateScreen={screenActions.updateScreen} + />} + {showTemplateModal && setShowTemplateModal(false)} + />} +
+ ) +} + +interface ScreenCardProps { + screen: Screen, + selected: boolean, + setCurScreenIndex: (index: number) => void + index: number, + activeDropdown: number, + setActiveDropdown: (index: number) => void, + isLast: boolean, + closeDropdown: () => void, + deleteScreen: (index: number) => void, + startEdit: (index: number, curName: string) => void + selectComponent: (index: number) => void +} + +function ScreenCard({ + screen, + selected, + setCurScreenIndex, + index, + activeDropdown, + setActiveDropdown, + isLast, + closeDropdown, + deleteScreen, + startEdit, + selectComponent +}: ScreenCardProps) { + + const handleSelectScreen = (index: number) => { + selectComponent(-1); + setCurScreenIndex(index) + } + + const handleEditAction = (index: number, name: string) => { + startEdit(index, name) + closeDropdown() + }; + const handleDeleteAction = (index: number) => { + deleteScreen(index); + closeDropdown() + }; + + + return ( +
+
{ handleSelectScreen(index) }} className="flex items-center p-3 cursor-pointer"> +
+
+ {screen.name} +
+
+ +
+ + {activeDropdown === index && ( +
+ + {!isLast && ( + <> +
+ + + )} +
+ )} + +
+ ) +} + +interface AddScreenModalProps { + close: () => void, + addScreen: (name: string) => void, + editIndex: number, + editName: string, + updateScreen: (index: number, name: string) => void + selectComponent: (index: number) => void +} + +function AddScreenModal({ close, addScreen, editName, editIndex, updateScreen, selectComponent }: AddScreenModalProps) { + + const [newScreenName, setNewScreenName] = useState(editIndex !== -1 ? editName : ""); + + const handleCancelCreate = () => { + setNewScreenName(""); + close(); + } + + const handleCreateConfirm = () => { + selectComponent(-1); + if (editIndex === -1) { + addScreen(newScreenName); + } else { + updateScreen(editIndex, newScreenName); + } + setNewScreenName(""); + close(); + } + + return ( +
+
+
+

新しいスクリーンを作成

+ +
+ +
+ + setNewScreenName(e.target.value)} + placeholder="スクリーン名を入力してください" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + autoFocus + /> +
+ +
+ + +
+
+
+ ) +} + +interface AddTemplateModalProps { + close: () => void, + tableData: useTablesData, + screenData: useScreenData + screenActions: useScreenActions + componenetActions: useComponentActions +} + +function AddTemplateModal({ close, tableData, screenData, screenActions, componenetActions }: AddTemplateModalProps) { + + const [type, setType] = useState<"add" | "list" | "delete" | "update" | "search" | "">(""); + const [screenName, setScreenName] = useState(""); + + const handleOutsideClick = () => { + close(); + } + + const handleTypeChange = (e: React.ChangeEvent) => { + const selectedType = e.target.value as "add" | "list" | "delete" | "update" | "search" | ""; + setType(selectedType); + } + + + return ( +
+
{ e.stopPropagation() }}> +
+

テンプレートを選択

+ +
+
+ + setScreenName(e.target.value)} + value={screenName} + /> +
+
+ + +
+ {type === "add" && } +
+
+ ) +} + +interface AddTemplateProps { + tableData: useTablesData, + componenetActions: useComponentActions, + screenData: useScreenData, + screenActions: useScreenActions + screenName: string + close: () => void +} + +function AddTemplate({ tableData, componenetActions, screenName, screenActions, screenData, close }: AddTemplateProps) { + + const tables = useMemo(() => tableData.tables, [tableData.tables]) + const [selectedTableId, setSelectedTableId] = useState(""); + const [successScreen, setSuccessScreen] = useState(""); + + const confirm = async () => { + const MARGIN_X = 50; + const COLUMN_MARGIN_Y = 20; + const LABEL_MARGIN_Y = 25; + const TEXT_WIDTH = 650; + const TEXT_HEIGHT = 25; + const INPUT_WIDTH = 650; + const INPUT_HEIGHT = 40; + + const screenId = await screenActions.addScreen(screenName); + componenetActions.selectComponent(-1); + const table = tables.find((table) => table.id === selectedTableId); + const actionData: { [key: string]: string } = {}; + const components: Component[] = []; + for (let i = 0; i < (table?.columns ?? []).length; i++) { + const column = table?.columns[i]; + let columnName; + if (!(column?.foreignTableName)) { + columnName = column?.name; + } else { + const foreignTable = tables.find((table) => table.name === column.foreignTableName); + columnName = `${foreignTable?.columns.find(col => col.isPrimary)?.name}Of${capitalizeFirst(foreignTable?.name)}`; + } + const label: TextComponent = { + id: "w" + self.crypto.randomUUID().replace(/-/g, ""), + screenId: screenId, + index: i * 2, + name: `${table?.name}${capitalizeFirst(columnName)}Label`, + type: ComponentType.Text, + uiData: { + posX: MARGIN_X * (i % 2 + 1) + INPUT_WIDTH * (i % 2), + posY: COLUMN_MARGIN_Y * (Math.floor(i / 2) + 1) + (TEXT_HEIGHT + INPUT_HEIGHT + LABEL_MARGIN_Y) * (Math.floor(i / 2)), + width: TEXT_WIDTH, + height: TEXT_HEIGHT + }, + generalData: { + text: `${columnName}` + }, + style: {} + } + const field: TextFieldComponent = { + id: "w" + self.crypto.randomUUID().replace(/-/g, ""), + screenId: screenId, + index: i * 2 + 1, + name: `${table?.name}${capitalizeFirst(columnName)}Input`, + type: ComponentType.TextField, + uiData: { + posX: MARGIN_X * (i % 2 + 1) + INPUT_WIDTH * (i % 2), + posY: (COLUMN_MARGIN_Y + LABEL_MARGIN_Y) * (Math.floor(i / 2) + 1) + (TEXT_HEIGHT + INPUT_HEIGHT) * (Math.floor(i / 2)), + width: INPUT_WIDTH, + height: INPUT_HEIGHT + }, + generalData: { + text: "" + }, + style: {} + } + actionData[columnName ?? ""] = `\${${table?.name}${capitalizeFirst(columnName)}Input}` + components.push(label); + components.push(field); + } + const confirmButton: ButtonComponent = { + id: "w" + self.crypto.randomUUID().replace(/-/g, ""), + name: `add${capitalizeFirst(table?.name)}ConfirmButton`, + screenId: screenId, + index: (table?.columns.length ?? 0) * 2, + type: ComponentType.Button, + uiData: { + posX: 1200, + posY: 750, + width: 150, + height: 40 + }, + generalData: { + text: "登録" + }, + style: {}, + action: { + type: "addData", + action: { + tableName: table?.name ?? "", + newData: actionData, + successScreenName: successScreen, + failScreenName: "" + } + } + } + const backButton: ButtonComponent = { + id: "w" + self.crypto.randomUUID().replace(/-/g, ""), + name: `add${capitalizeFirst(table?.name)}BackButton`, + screenId: screenId, + index: (table?.columns.length ?? 0) * 2 + 1, + type: ComponentType.Button, + uiData: { + posX: 200, + posY: 750, + width: 150, + height: 40 + }, + generalData: { + text: "戻る" + }, + style: {}, + action: { + type: "navigate", + action: { + nextScreenName: "_back" + } + } + } + components.push(confirmButton); + components.push(backButton) + componenetActions.addComponents(components, screenId); + close(); + } + + return ( +
+
+ + +
+
+ + +
+
+ +
+
+ ) +} \ No newline at end of file diff --git a/src/components/sidebar/sidebar.tsx b/src/components/sidebar/sidebar.tsx new file mode 100644 index 0000000..c5da47b --- /dev/null +++ b/src/components/sidebar/sidebar.tsx @@ -0,0 +1,142 @@ +import { CgScreen } from "react-icons/cg"; +import { IoMdAdd } from "react-icons/io"; +import { FaLayerGroup } from "react-icons/fa"; +import { FaDatabase } from "react-icons/fa"; +import { ScreenList } from "./screenList"; +import { AddComponentMenu } from "./addComponentMenu"; +import type { Dispatch, SetStateAction } from "react"; +import { ComponentList } from "./componentList"; +import { TableList } from "./tableList"; +import type { useScreenActions, useScreenData } from "../../hooks/useScreen"; +import type { useComponentActions, useComponentData } from "../../hooks/useComponent"; +import type { useTablesActions, useTablesData } from "../../hooks/useTables"; + +interface SidebarProps { + activeTabId: string + screenData: useScreenData + screenActions: useScreenActions + componentData: useComponentData + componentActions: useComponentActions + tableData: useTablesData + tableActions: useTablesActions +} + +export function Sidebar({ + activeTabId, + screenData, + screenActions, + componentData, + componentActions, + tableData, + tableActions, +}: SidebarProps) { + + return ( +
+
+ {activeTabId === "screens" && + + } + {activeTabId === "layer" && + + } + {activeTabId === "components" && + + } + {activeTabId === "tables" && + + } +
+
+ ) + +} + +export function SidebarIcons({ + activeTab, + setActiveTab +}: { + activeTab: string, + setActiveTab: Dispatch> +}) { + return ( +
+ + + + + + + + + + + + +
+ ) +} + + +function IconButton({ + children, + id, + activeTab, + setActiveTab +}: { children: React.ReactNode, id: string, activeTab: string, setActiveTab: Dispatch> }) { + const handleTabClick = () => { + if (activeTab === id) { + setActiveTab("") + } else { + setActiveTab(id) + } + } + + return ( + + ) +} \ No newline at end of file diff --git a/src/components/sidebar/tableList.tsx b/src/components/sidebar/tableList.tsx new file mode 100644 index 0000000..a795ce0 --- /dev/null +++ b/src/components/sidebar/tableList.tsx @@ -0,0 +1,598 @@ +import type { useTablesActions, useTablesData } from "../../hooks/useTables"; +import { DataType, translateDataType } from "../../types/table"; +import type { Column, Table } from "../../types/table"; +import { useMemo, useState } from "react" +import { FiX } from "react-icons/fi"; +import { MdDeleteOutline, MdOutlineDelete, MdOutlineEdit } from "react-icons/md"; + +interface TalbeListProps { + tableData: useTablesData, + tableActions: useTablesActions +} + +export function TableList({ tableData, tableActions }: TalbeListProps) { + + const [showAddModal, setShowAddModal] = useState(false); + const [editTableIndex, setEditTableIndex] = useState(-1); + + const handleAddTable = () => { + setShowAddModal(true); + } + + const handleDeleteTable = (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + tableActions.deleteTable(id) + } + + return ( +
+
+

テーブル

+
+ +
+ {tableData.tables.map((table, idx) => +
{ setEditTableIndex(idx); setShowAddModal(true) }} + className={`group relative rounded-xl border transition-all duration-200 hover:border-gray-300 hover:shadow-sm bg-sky-100 border-gray-200 bg-white border-gray-200`} + > +
+
+
+ {table.name} +
+
+ +
+
+ )} +
+ +
+ +
+ {showAddModal && { setEditTableIndex(-1); setShowAddModal(false) }} + tables={tableData.tables} + editTable={editTableIndex === -1 ? null : tableData.tables[editTableIndex]} + addTable={tableActions.addTable} + updateTable={tableActions.updateTable} + />} +
+ ) +} + +interface AddTableModalProps { + close: () => void + tables: Table[] + editTable: Table | null + addTable: (table: Table) => void + updateTable: (updatedTable: Table) => void +} + +function AddTableModal({ close, tables, editTable, addTable, updateTable }: AddTableModalProps) { + + const [pageNum, setPageNum] = useState(0); + const [tableName, setTableName] = useState(editTable === null ? "" : editTable.name); + const [columns, setColumns] = useState(editTable === null ? [] : editTable.columns); + const [editColumnIndex, setEditColumnIndex] = useState(-1); + + const handleCancel = () => { + setPageNum(0); + setTableName(""); + close(); + } + + const handleNext = () => { + setPageNum(cur => cur + 1); + } + + const handlePrev = () => { + setPageNum(cur => cur - 1); + } + + const handleConfirm = () => { + if (editTable === null) { + const newTable: Table = { + name: tableName, + columns: columns, + id: "t" + self.crypto.randomUUID().replace(/-/g, ""), + } + addTable(newTable); + } else { + const newTable: Table = { + name: tableName, + columns: columns, + id: editTable.id + } + updateTable(newTable) + } + setPageNum(0); + setTableName(""); + setColumns([]); + setEditColumnIndex(-1); + close(); + } + + const setColumn = (column: Column) => { + if (editColumnIndex === -1) { + setColumns(cur => [...cur, column]); + } else { + setColumns(cur => cur.map((col, idx) => idx === editColumnIndex ? column : col)) + } + } + + const editColumn = (index: number, isForeingColumn: boolean) => { + setEditColumnIndex(index) + setPageNum(isForeingColumn ? 3 : 2); + } + + const deleteColumn = (index: number) => { + setColumns(cur => cur.filter((_, idx) => index != idx)) + } + + return ( +
+
+
+

{editTable === null ? "新しいテーブルを作成" : "テーブルを編集"}

+ +
+ + {pageNum == 0 &&
+ + setTableName(e.target.value)} + placeholder="スクリーン名を入力してください" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + autoFocus + /> +
+ + +
+
} + + {pageNum == 1 &&
+ +
+ + +
+ +
+ + +
+
} + + {pageNum == 2 &&
+ { setEditColumnIndex(-1); setPageNum(1) }} + column={editColumnIndex === -1 ? null : columns[editColumnIndex]} + /> +
} + + {pageNum === 3 &&
+ { setEditColumnIndex(-1); setPageNum(1) }} + column={editColumnIndex === -1 ? null : columns[editColumnIndex]} + tables={tables} + tableId={editTable !== null ? editTable.id : ""} + /> +
} + +
+
+ ) +} + +interface ColumnListProps { + columns: Column[] + tables: Table[] + deleteColumn: (index: number) => void + editColumn: (index: number, isForeingColumn: boolean) => void +} + +function ColumnList({ columns, tables, deleteColumn, editColumn }: ColumnListProps) { + + const getForeignColType = (refId: string, names: string[]): DataType[] => { + const cols = tables.find(table => table.name === refId)?.columns; + if (!cols) { + return []; + } + return names.map(name => cols.find(col => col.name === name)?.type as DataType); + } + + return ( +
+ {columns.length === 0 ? +
+ まだカラムがありません +
: +
+
+
+ 主キー +
+
+ 名前 +
+
+ 型 +
+
+ 参照テーブル +
+
+ その他 +
+
+ {columns.map((col, idx) => { + if (!col.foreignTableName) { + return ( +
+
+ +
+
+ {col.name} +
+
+ {col.type != "foreign" ? translateDataType(col.type) : ""} +
+
+
+
+ + +
+
+ ) + } + return ( +
+
+ +
+
+ {col.name} +
+
+ {col.foreignTableColumns?.map((col, idx) => +
+ {col} +
+ )} +
+
+ {getForeignColType(col.foreignTableName, col.foreignTableColumns!).map((type, idx) => +
+ {translateDataType(type)} +
+ )} +
+
+ {tables.find(table => table.name === col.foreignTableName)?.name ?? "error"} +
+
+ + +
+
+ ) + })} +
+ } +
+ ) +} + +interface CreateColumnScreenProps { + setColumn: (col: Column) => void + close: () => void + column: Column | null +} + +function CreateColumnScreen({ setColumn, close, column }: CreateColumnScreenProps) { + + const [name, setName] = useState(column === null ? "" : column.name); + const [isPrimary, setIsPrimary] = useState(column === null ? false : column.isPrimary); + const [type, setType] = useState(column === null ? DataType.String : column.type as DataType); + + const validate = (): boolean => { + if (!name.trim()) { + return false; + } + return true; + } + + const handleConfirm = () => { + const column: Column = { + name: name, + type: type, + isPrimary: isPrimary + }; + setColumn(column); + close(); + } + + const handleCancel = () => { + close(); + } + + return ( +
+
+ + setName(e.target.value)} + placeholder="カラム名を入力してください" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + autoFocus + /> +
+
+ + setIsPrimary(e.target.checked)} + /> +
+
+ + +
+
+ + +
+
+ ) +} + +interface CreateForeignColumnScreenProps { + setColumn: (col: Column) => void + close: () => void + tables: Table[] + tableId: string + column: Column | null +} + +function CreateForeignColumnScreen({ setColumn, close, tables, tableId, column }: CreateForeignColumnScreenProps) { + + const [name, setName] = useState(column?.name ?? "") + const [referencedTableId, setReferencedTableId] = useState(column?.foreignTableName ?? ""); + const referencedTableColumns = useMemo( + () => tables.find((table) => table.name === referencedTableId)?.columns ?? [], + [referencedTableId, tables] + ) + const [selectedColumns, setSelectedColumns] = useState(column?.foreignTableColumns ?? []); + + const selectRefTable = (tableId: string) => { + setReferencedTableId(tableId); + setSelectedColumns([]); + } + + const validate = (): boolean => { + if (!name.trim()) return false; + if (selectedColumns.length === 0) return false; + return true; + } + + const handleConfirm = () => { + const newColumn: Column = { + name: name, + isPrimary: false, + type: "foreign", + foreignTableName: referencedTableId, + foreignTableColumns: selectedColumns, + } + setColumn(newColumn); + setReferencedTableId(""); + setSelectedColumns([]); + setName(""); + close(); + } + + const handleCancel = () => { + setReferencedTableId(""); + setSelectedColumns([]); + setName(""); + close(); + } + + const selectColumn = (name: string) => { + setSelectedColumns(cur => cur.includes(name) ? cur.filter(col => col !== name) : [...cur, name]); + } + + return ( +
+
+ + setName(e.target.value)} + placeholder="カラム名を入力してください" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-colors" + autoFocus + /> +
+
+ + +
+
+ +
+ {referencedTableColumns.map((col, idx) => +
+ +
+ )} +
+
+
+ + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/workspace.tsx b/src/components/workspace.tsx new file mode 100644 index 0000000..624f35b --- /dev/null +++ b/src/components/workspace.tsx @@ -0,0 +1,57 @@ +import { Sidebar, SidebarIcons } from "./sidebar/sidebar"; +import Editor from "./editor/editor"; +import Property from "./property/property"; +import type { ScreenDataAndActions } from "../hooks/useScreen"; +import type { ComponentDataAndActions } from "../hooks/useComponent"; +import { useState } from "react"; +import type { TableDataAndActions } from "../hooks/useTables"; + +export default function Workspace({ + screenDataAndActions, + componentDataAndActions, + tableDataAndActions + }: { + screenDataAndActions: ScreenDataAndActions, + componentDataAndActions: ComponentDataAndActions, + tableDataAndActions: TableDataAndActions + + }) { + + + const [activeTabId, setActiveTabId] = useState(""); + + return ( +
+ +
+ +
+ +
+
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/constants/activeTab.ts b/src/constants/activeTab.ts new file mode 100644 index 0000000..812a030 --- /dev/null +++ b/src/constants/activeTab.ts @@ -0,0 +1,8 @@ +export const ActiveTab = { + GENERAL: 0, + STYLE: 1, + ACTION: 2, +} as const; + +export type ActiveTab = + typeof ActiveTab[keyof typeof ActiveTab]; \ No newline at end of file diff --git a/src/lib/jsonExport.ts b/src/lib/jsonExport.ts new file mode 100644 index 0000000..7507611 --- /dev/null +++ b/src/lib/jsonExport.ts @@ -0,0 +1,12 @@ +import type { Project } from "../types/project"; + +export function jsonExport(project: Project) { + const text = JSON.stringify(project, null, 2); + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }) + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${project.title}_JSON.json` + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/lib/stringUtils.ts b/src/lib/stringUtils.ts new file mode 100644 index 0000000..5f47d77 --- /dev/null +++ b/src/lib/stringUtils.ts @@ -0,0 +1,5 @@ +export function capitalizeFirst(text: string | undefined): string { + if (!text) return "" + if (text.length === 0) return text; + return text[0].toUpperCase() + text.slice(1); +} \ No newline at end of file