diff --git a/prisma/migrations/20251113084438_add_code_state/migration.sql b/prisma/migrations/20251113084438_add_code_state/migration.sql new file mode 100644 index 0000000..0cc905a --- /dev/null +++ b/prisma/migrations/20251113084438_add_code_state/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE "CodeState" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "codeData" TEXT NOT NULL, + "codeName" TEXT NOT NULL, + + CONSTRAINT "CodeState_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/migrations/20251113132626_rename/migration.sql b/prisma/migrations/20251113132626_rename/migration.sql new file mode 100644 index 0000000..9799df6 --- /dev/null +++ b/prisma/migrations/20251113132626_rename/migration.sql @@ -0,0 +1,33 @@ +/* + Warnings: + + - You are about to drop the `BoardState` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `CodeState` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropTable +DROP TABLE "BoardState"; + +-- DropTable +DROP TABLE "CodeState"; + +-- CreateTable +CREATE TABLE "Board" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "data" JSONB NOT NULL, + "name" TEXT NOT NULL, + "preview" JSONB NOT NULL, + + CONSTRAINT "Board_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Code" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "data" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "Code_pkey" PRIMARY KEY ("id") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4719673..fc26eee 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -15,10 +15,17 @@ datasource db { url = env("DATABASE_URL") } -model BoardState { +model Board { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) - boardData Json - boardName String - boardPreview Json + data Json + name String + preview Json } + +model Code { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + data String + name String +} \ No newline at end of file diff --git a/src/routes/api.ts b/src/lib/api/board.ts similarity index 97% rename from src/routes/api.ts rename to src/lib/api/board.ts index a20f085..f2b6969 100644 --- a/src/routes/api.ts +++ b/src/lib/api/board.ts @@ -29,9 +29,9 @@ export async function saveBoard(data: { board: boolean[][]; name: string }, isJa export type BoardListItem = { id: number; - boardName: string; + name: string; createdAt: string; - boardPreview: boolean[][]; + preview: boolean[][]; }; export async function fetchBoardList(isJapanese: boolean): Promise { diff --git a/src/lib/api/code.ts b/src/lib/api/code.ts new file mode 100644 index 0000000..d40f414 --- /dev/null +++ b/src/lib/api/code.ts @@ -0,0 +1,84 @@ +export async function saveCode(data: { code: string; name: string }, isJapanese: boolean) { + try { + const response = await fetch("/api/code", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error("Failed to communicate with the server."); + } + + if (isJapanese) { + alert("コードを保存しました!"); + } else { + alert("Code saved!"); + } + } catch (err) { + console.error("Save Error:", err); + if (isJapanese) { + alert("保存に失敗しました。"); + } else { + alert("Failed to save."); + } + } +} + +export type CodeListItem = { + id: number; + name: string; + createdAt: string; +}; + +export async function fetchCodeList(isJapanese: boolean): Promise { + try { + const response = await fetch("/api/code"); + + if (!response.ok) { + if (response.status === 404) { + throw new Error("There is no saved data."); + } else { + throw new Error("Failed to communicate with the server."); + } + } + + const codeList = await response.json(); + + return codeList as CodeListItem[]; + } catch (err) { + console.error("Load error", err); + if (isJapanese) { + alert("読み込みに失敗しました。"); + } else { + alert("Failed to load."); + } + } +} + +export async function loadCodeById(id: number, isJapanese: boolean): Promise { + try { + const response = await fetch(`/api/code?id=${id}`); + + if (!response.ok) { + if (response.status === 404) { + throw new Error("The specified ID data was not found."); + } else { + throw new Error("Failed to communicate with the server."); + } + } + + const loadedCode = await response.json(); + + return loadedCode as string; + } catch (err) { + console.error("Load error", err); + if (isJapanese) { + alert("読み込みに失敗しました。"); + } else { + alert("Failed to load."); + } + } +} diff --git a/src/lib/board-preview.ts b/src/lib/board-preview.ts index 79736ab..97ec012 100644 --- a/src/lib/board-preview.ts +++ b/src/lib/board-preview.ts @@ -4,7 +4,7 @@ const PREVIEW_SIZE = 20; * 任意のサイズの盤面データから、中央 20x20 のプレビューを生成します。 * 20x20 に満たない場合は、中央に配置し、周囲を false (空白) で埋めます。 */ -export function createPreview(boardData: boolean[][]): boolean[][] { +export function createBoardPreview(boardData: boolean[][]): boolean[][] { const boardHeight = boardData.length; const boardWidth = boardData[0]?.length || 0; diff --git a/src/lib/components/BoardModals.svelte b/src/lib/components/BoardModals.svelte new file mode 100644 index 0000000..d674bed --- /dev/null +++ b/src/lib/components/BoardModals.svelte @@ -0,0 +1,145 @@ + + + + + + + + + + + diff --git a/src/lib/components/CodeModals.svelte b/src/lib/components/CodeModals.svelte new file mode 100644 index 0000000..a1efac5 --- /dev/null +++ b/src/lib/components/CodeModals.svelte @@ -0,0 +1,97 @@ + + + + + + + + + diff --git a/src/lib/models/BoardManager.svelte.ts b/src/lib/models/BoardManager.svelte.ts new file mode 100644 index 0000000..8583b18 --- /dev/null +++ b/src/lib/models/BoardManager.svelte.ts @@ -0,0 +1,53 @@ +import { createBoardPreview } from "$lib/board-preview"; +import { saveBoard, fetchBoardList, loadBoardById, type BoardListItem } from "$lib/api/board"; + +type SaveState = + | { saving: false } + | { saving: true; data: boolean[][]; name: string; preview: boolean[][] }; + +type LoadState = + | { state: "closed" } + | { state: "loading" } + | { state: "list"; list: BoardListItem[] }; + +export class BoardManager { + saveState = $state({ saving: false }); + loadState = $state({ state: "closed" }); + + constructor() {} + + openSaveModal(board: boolean[][]) { + const preview = createBoardPreview(board); + this.saveState = { saving: true, data: board, name: "", preview: preview }; + } + + closeSaveModal() { + this.saveState = { saving: false }; + } + + async save(isJapanese: boolean) { + if (!this.saveState.saving) return; + const name = this.saveState.name.trim() === "" ? "Unnamed Board" : this.saveState.name.trim(); + await saveBoard({ board: this.saveState.data, name }, isJapanese); + this.closeSaveModal(); + } + + async openLoadModal(isJapanese: boolean) { + this.loadState = { state: "loading" }; + const list = await fetchBoardList(isJapanese); + if (list) { + this.loadState = { state: "list", list }; + } else { + this.loadState = { state: "closed" }; + } + } + + closeLoadModal() { + this.loadState = { state: "closed" }; + } + + async load(id: number, isJapanese: boolean): Promise { + this.closeLoadModal(); + return await loadBoardById(id, isJapanese); + } +} diff --git a/src/lib/models/CodeManager.svelte.ts b/src/lib/models/CodeManager.svelte.ts new file mode 100644 index 0000000..f1b0038 --- /dev/null +++ b/src/lib/models/CodeManager.svelte.ts @@ -0,0 +1,49 @@ +import { saveCode, fetchCodeList, loadCodeById, type CodeListItem } from "$lib/api/code"; + +type SaveState = { saving: false } | { saving: true; data: string; name: string }; + +type LoadState = + | { state: "closed" } + | { state: "loading" } + | { state: "list"; list: CodeListItem[] }; + +export class CodeManager { + saveState = $state({ saving: false }); + loadState = $state({ state: "closed" }); + + constructor() {} + + openSaveModal(code: string) { + this.saveState = { saving: true, data: code, name: "" }; + } + + closeSaveModal() { + this.saveState = { saving: false }; + } + + async save(isJapanese: boolean) { + if (!this.saveState.saving) return; + const name = this.saveState.name.trim() === "" ? "Unnamed Code" : this.saveState.name.trim(); + await saveCode({ code: this.saveState.data, name }, isJapanese); + this.closeSaveModal(); + } + + async openLoadModal(isJapanese: boolean) { + this.loadState = { state: "loading" }; + const list = await fetchCodeList(isJapanese); + if (list) { + this.loadState = { state: "list", list }; + } else { + this.loadState = { state: "closed" }; + } + } + + closeLoadModal() { + this.loadState = { state: "closed" }; + } + + async load(id: number, isJapanese: boolean): Promise { + this.closeLoadModal(); + return await loadCodeById(id, isJapanese); + } +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index d8931bb..b13d903 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -5,8 +5,10 @@ import lifeGameJS from "@/iframe/life-game.js?raw"; import patterns from "$lib/board-templates"; import * as icons from "$lib/icons/index.ts"; - import { saveBoard, fetchBoardList, loadBoardById, type BoardListItem } from "./api.ts"; - import { createPreview } from "$lib/board-preview"; + import { BoardManager } from "$lib/models/BoardManager.svelte"; + import { CodeManager } from "$lib/models/CodeManager.svelte"; + import BoardModals from "$lib/components/BoardModals.svelte"; + import CodeModals from "$lib/components/CodeModals.svelte"; let editingCode = $state(lifeGameJS); let appliedCode = $state(lifeGameJS); @@ -23,6 +25,9 @@ let generationFigure = $state(0); let sizeValue = $state(20); + const boardManager = new BoardManager(); + const codeManager = new CodeManager(); + let timer: "running" | "stopped" = $state("stopped"); let intervalMs = $state(1000); $effect(() => { @@ -33,17 +38,6 @@ return () => clearInterval(timerId); }); - type SaveState = - | { saving: false } - | { saving: true; boardData: boolean[][]; boardName: string; boardPreview: boolean[][] }; - let saveState: SaveState = $state({ saving: false }); - - type LoadState = - | { state: "closed" } - | { state: "loading" } - | { state: "list"; list: BoardListItem[] }; - let loadState: LoadState = $state({ state: "closed" }); - type OngoingEvent = | "play" | "pause" @@ -79,9 +73,7 @@ break; } case "save_board": { - const board = event.data.data as boolean[][]; - const preview = createPreview(board); - saveState = { saving: true, boardData: board, boardName: "", boardPreview: preview }; + boardManager.openSaveModal(event.data.data as boolean[][]); break; } default: { @@ -102,37 +94,20 @@ preview_iframe?.contentWindow?.postMessage({ type: event, data: message }, "*"); } - async function handleSave() { - if (!saveState.saving) return; - - const name = saveState.boardName.trim() === "" ? "Unnamed Board" : saveState.boardName.trim(); - - await saveBoard({ board: saveState.boardData, name: name }, isJapanese); - - saveState = { saving: false }; - } - - async function handleLoad() { - loadState = { state: "loading" }; - - const list = await fetchBoardList(isJapanese); - - if (list) { - loadState = { state: "list", list }; - } else { - loadState = { state: "closed" }; - } - } - - async function selectBoard(id: number) { - loadState = { state: "closed" }; - - const board = await loadBoardById(id, isJapanese); + async function onBoardSelect(id: number) { + const board = await boardManager.load(id, isJapanese); if (board) { sizeValue = board.length; sendEvent("apply_board", board); } } + + async function onCodeSelect(id: number) { + const code = await codeManager.load(id, isJapanese); + if (code) { + editingCode = code; + } + } - - - - - - - + + - - diff --git a/src/routes/api/board/+server.ts b/src/routes/api/board/+server.ts index 80e2488..2afaa69 100644 --- a/src/routes/api/board/+server.ts +++ b/src/routes/api/board/+server.ts @@ -1,6 +1,6 @@ import { json } from "@sveltejs/kit"; import { prisma } from "@/lib/prisma.server.ts"; -import { createPreview } from "@/lib/board-preview.js"; +import { createBoardPreview } from "@/lib/board-preview.js"; import * as v from "valibot"; const BoardSchema = v.object({ @@ -9,7 +9,7 @@ const BoardSchema = v.object({ }); export async function POST({ request }) { - let requestData; + let requestData: unknown; try { requestData = await request.json(); } catch (error) { @@ -24,13 +24,13 @@ export async function POST({ request }) { } const { board, name } = result.output; - const preview = createPreview(board); + const preview = createBoardPreview(board); - const newState = await prisma.boardState.create({ + const newState = await prisma.board.create({ data: { - boardData: board, - boardName: name, - boardPreview: preview, + data: board, + name: name, + preview: preview, }, }); @@ -47,27 +47,27 @@ export async function GET({ url }) { return json({ message: "無効なIDです。" }, { status: 400 }); } - const state = await prisma.boardState.findUnique({ + const state = await prisma.board.findUnique({ where: { id: id }, - select: { boardData: true }, + select: { data: true }, }); if (!state) { return json({ message: `ID: ${id} の盤面は見つかりません。` }, { status: 404 }); } - return json(state.boardData); + return json(state.data); } else { //IDが指定されなかった場合、全ての盤面のリストを返す - const allStates = await prisma.boardState.findMany({ + const allStates = await prisma.board.findMany({ orderBy: { createdAt: "desc", }, select: { id: true, - boardName: true, + name: true, createdAt: true, - boardPreview: true, + preview: true, }, }); diff --git a/src/routes/api/code/+server.ts b/src/routes/api/code/+server.ts new file mode 100644 index 0000000..79eec40 --- /dev/null +++ b/src/routes/api/code/+server.ts @@ -0,0 +1,76 @@ +import { json } from "@sveltejs/kit"; +import { prisma } from "@/lib/prisma.server.ts"; +import * as v from "valibot"; + +const CodeSchema = v.object({ + code: v.pipe(v.string(), v.minLength(1, "コードが空白です。")), + name: v.pipe(v.string(), v.minLength(1, "コード名は必須です。")), +}); + +export async function POST({ request }) { + let requestData: unknown; + try { + requestData = await request.json(); + } catch (error) { + console.error("Request body JSON parsing failed:", error); + return json({ message: "無効なリクエスト形式です。" }, { status: 400 }); + } + + const result = v.safeParse(CodeSchema, requestData); + if (!result.success) { + console.error("Request validation failed:", result.issues); + return json({ message: "無効なリクエストデータです。" }, { status: 400 }); + } + + const { code, name } = result.output; + + const newState = await prisma.code.create({ + data: { + data: code, + name: name, + }, + }); + + return json({ id: newState.id }, { status: 201 }); +} + +export async function GET({ url }) { + const codeId = url.searchParams.get("id"); + + if (codeId) { + //IDが指定された場合、そのIDのコードを返す + const id = parseInt(codeId, 10); + if (isNaN(id)) { + return json({ message: "無効なIDです。" }, { status: 400 }); + } + + const state = await prisma.code.findUnique({ + where: { id: id }, + select: { data: true }, + }); + + if (!state) { + return json({ message: `ID: ${id} のコードは見つかりません。` }, { status: 404 }); + } + + return json(state.data); + } else { + //IDが指定されなかった場合、全てのコードのリストを返す + const allStates = await prisma.code.findMany({ + orderBy: { + createdAt: "desc", + }, + select: { + id: true, + name: true, + createdAt: true, + }, + }); + + if (!allStates || allStates.length === 0) { + return json({ message: "保存されているコードがありません。" }, { status: 404 }); + } + + return json(allStates); + } +}