+
+
スクリーン
+
+
+
+ {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">
+
+
+
+
+ {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`}
+ >
+
+
+
+
+
+ )}
+
+
+
+
+
+ {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