CRUD操作によるデータ管理 (Svelte & MongoDB)

1. +page.server.ts (サーバーサイド)

この部分はサーバーサイドのロジックを担当しています。主に認証とデータベース操作を行います。

ロード関数

export const load: PageServerLoad = async ({ locals }) => {
  await connect_to_db(); // データベースに接続

  const session = await locals.auth.validate();
  if (!session) {
    throw redirect(303, "/login"); // ログインしていない場合はログインページにリダイレクト
  }

  // 記事を取得
  const articles = await Article.find({ user_id: session.user.userId })
    .sort({ createdAt: -1 }) // 作成日時で降順にソート
    .select("_id title content createdAt updatedAt"); // 必要なフィールドのみ選択

  return {
    articles: JSON.parse(JSON.stringify(articles)), // Mongoose ドキュメントをプレーンなオブジェクトに変換
    user: session.user, // ログインユーザー情報
  };
};

このロード関数は以下の処理を行います:

  1. データベースに接続
  2. ユーザーのセッションを検証し、ログインしていなければログインページにリダイレクト
  3. ログインユーザーの記事を作成日時の降順で取得
  4. 記事データとユーザー情報をページコンポーネントに渡す

アクション関数

export const actions = {
  create: async ({ request, locals }) => {
    await connect_to_db(); // データベースに接続
    const session = await locals.auth.validate();

    if (!session) return fail(401, { error: "Unauthorized" }); // ログインしていない場合はエラー

    const formData = await request.formData();
    const title = formData.get("title")?.toString();
    const content = formData.get("content")?.toString();

    if (!title || !content) {
      return fail(400, { error: "Title and content are required" }); // タイトルと内容が必須
    }

    try {
      // 記事を作成
      await Article.create({
        title,
        content,
        user_id: session.user.userId, // ユーザーIDを設定
      });
    } catch (error) {
      console.error(error);
      return fail(500, { error: "Failed to create article" }); // エラー処理
    }

    return { success: true };
  },

  update: async ({ request, locals }) => {
    await connect_to_db(); // データベースに接続
    const session = await locals.auth.validate();

    if (!session) return fail(401, { error: "Unauthorized" }); // ログインしていない場合はエラー

    const formData = await request.formData();
    const id = formData.get("id")?.toString();
    const title = formData.get("title")?.toString();
    const content = formData.get("content")?.toString();

    if (!id || !title || !content) {
      return fail(400, { error: "All fields are required" }); // すべてのフィールドが必須
    }

    try {
      // 記事を更新
      await Article.findByIdAndUpdate(id, {
        title,
        content,
        updatedAt: new Date(), // 更新日時を設定
      });
    } catch (error) {
      console.error(error);
      return fail(500, { error: "Failed to update article" }); // エラー処理
    }

    return { success: true };
  },

  delete: async ({ request, locals }) => {
    await connect_to_db(); // データベースに接続
    const session = await locals.auth.validate();

    if (!session) return fail(401, { error: "Unauthorized" }); // ログインしていない場合はエラー

    const formData = await request.formData();
    const id = formData.get("id")?.toString();

    if (!id) {
      return fail(400, { error: "Article ID is required" }); // 記事IDが必須
    }

    try {
      // 記事を削除
      await Article.findByIdAndDelete(id);
    } catch (error) {
      console.error(error);
      return fail(500, { error: "Failed to delete article" }); // エラー処理
    }

    return { success: true };
  },

  logout: async ({ locals }) => {
    const session = await locals.auth.validate();

    if (!session) return fail(401); // ログインしていない場合はエラー

    // セッションを無効化
    await auth.invalidateSession(session.sessionId);

    // ローカルのセッションをクリア
    locals.auth.setSession(null);

    // ログインページにリダイレクト
    throw redirect(303, "/login");
  },
} satisfies Actions;

アクション関数では4つの処理を実装しています:

  1. create: 新しい記事の作成
  2. update: 既存記事の更新
  3. delete: 記事の削除
  4. logout: ユーザーのログアウト処理

各アクションは認証チェックを行い、フォームデータを検証した上でデータベース操作を実行します。

2. +page.svelte (クライアントサイド)

スクリプト部分

<script lang="ts">
  import { enhance } from "$app/forms";
  import { invalidateAll } from "$app/navigation";
  import type { PageData } from "./$types";
  import type { SubmitFunction } from "@sveltejs/kit";

  interface Article {
    _id: string;
    title: string;
    content: string;
    createdAt: Date;
    updatedAt: Date;
  }

  export let data: PageData;
  let editingArticle: Article | null = null;
  let showCreateForm = false;
  let updating = false;

  const handleSubmit: SubmitFunction = () => {
    return async ({ update }) => {
      updating = true;
      try {
        await update(); // フォーム送信を処理
        await invalidateAll(); // ページをリフレッシュ
        editingArticle = null; // 編集モードを終了
        showCreateForm = false; // 作成フォームを非表示
      } catch (error) {
        console.error("Operation failed", error); // エラーハンドリング
      } finally {
        updating = false;
      }
    };
  };

  function startEdit(article: Article) {
    editingArticle = { ...article }; // 編集モードを開始
  }

  function cancelEdit() {
    editingArticle = null; // 編集モードをキャンセル
  }

  function confirmDelete(event: Event) {
    if (!confirm("Are you sure you want to delete this article?")) {
      event.preventDefault(); // 削除をキャンセル
    }
  }
</script>

スクリプト部分では:

  1. 必要なインポートと型定義
  2. 状態変数の定義(編集中の記事、フォーム表示状態、更新中フラグ)
  3. フォーム送信ハンドラー関数(handleSubmit
  4. 編集開始/キャンセル関数
  5. 削除確認関数

enhanceディレクティブを使用して、ページ全体をリロードせずにフォーム送信をハンドリングしています。

ヘッダー部分のHTML

<div class="min-h-screen bg-gray-50 max-w-5xl mx-auto py-4">
  <div class="max-w-4xl mx-auto px-4">
    <div class="flex justify-between items-center mb-6">
      <h1 class="text-3xl font-bold text-gray-900">My Articles</h1>
      <div class="flex gap-4">
        <form action="?/logout" method="POST" use:enhance>
          <button
            class="bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600 transition-colors"
            type="submit"
          >
            Logout
          </button>
        </form>
        {#if !showCreateForm}
          <button
            class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors"
            on:click={() => (showCreateForm = true)}
          >
            New Article
          </button>
        {/if}
      </div>
    </div>

ヘッダー部分には:

  1. ページタイトル(「My Articles」)
  2. ログアウトボタン
  3. 新規記事作成ボタン(フォームが表示されていない場合のみ)

新規記事作成フォーム

    {#if showCreateForm}
      <div class="mb-8 bg-white rounded-lg shadow p-6">
        <form
          method="POST"
          action="?/create"
          use:enhance={handleSubmit}
          class="space-y-4"
        >
          <div>
            <label for="title" class="block text-sm font-medium text-gray-700"
              >Title</label
            >
            <input
              type="text"
              id="title"
              name="title"
              required
              class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            />
          </div>
          <div>
            <label for="content" class="block text-sm font-medium text-gray-700"
              >Content</label
            >
            <textarea
              id="content"
              name="content"
              required
              rows="4"
              class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
            ></textarea>
          </div>
          <div class="flex justify-end gap-2">
            <button
              type="button"
              class="px-4 py-2 text-gray-700 hover:text-gray-900"
              on:click={() => (showCreateForm = false)}
              disabled={updating}
            >
              Cancel
            </button>
            <button
              type="submit"
              class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50"
              disabled={updating}
            >
              Create
            </button>
          </div>
        </form>
      </div>
    {/if}

新規記事作成フォームは条件付きでレンダリングされ:

  1. タイトル入力フィールド
  2. 内容入力用テキストエリア
  3. キャンセルボタンと作成ボタン
  4. ?/createアクションを呼び出すフォーム設定

記事一覧表示部分

    <div class="space-y-6">
      {#each data.articles as article (article._id)}
        <div class="bg-white rounded-lg shadow p-6">
          {#if editingArticle !== null && editingArticle._id === article._id}
            <form
              method="POST"
              action="?/update"
              use:enhance={handleSubmit}
              class="space-y-4"
            >
              <input type="hidden" name="id" value={article._id} />
              <div>
                <label
                  for="edit-title"
                  class="block text-sm font-medium text-gray-700">Title</label
                >
                <input
                  type="text"
                  id="edit-title"
                  name="title"
                  bind:value={editingArticle.title}
                  required
                  class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
                />
              </div>
              <div>
                <label
                  for="edit-content"
                  class="block text-sm font-medium text-gray-700">Content</label
                >
                <textarea
                  id="edit-content"
                  name="content"
                  bind:value={editingArticle.content}
                  required
                  rows="4"
                  class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
                ></textarea>
              </div>
              <div class="flex justify-end gap-2">
                <button
                  type="button"
                  class="px-4 py-2 text-gray-700 hover:text-gray-900"
                  on:click={cancelEdit}
                  disabled={updating}
                >
                  Cancel
                </button>
                <button
                  type="submit"
                  class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 transition-colors disabled:opacity-50"
                  disabled={updating}
                >
                  Save
                </button>
              </div>
            </form>
          {:else}
            <div>
              <div class="flex justify-between items-start mb-4">
                <h2 class="text-xl font-semibold text-gray-900">
                  {article.title}
                </h2>
                <div class="flex gap-2">
                  <button
                    class="text-blue-500 hover:text-blue-700 transition-colors"
                    on:click={() => startEdit(article)}
                  >
                    Edit
                  </button>
                  <form
                    method="POST"
                    action="?/delete"
                    use:enhance={handleSubmit}
                    class="inline"
                  >
                    <input type="hidden" name="id" value={article._id} />
                    <button
                      type="submit"
                      class="text-red-500 hover:text-red-700 transition-colors"
                      on:click={confirmDelete}
                    >
                      Delete
                    </button>
                  </form>
                </div>
              </div>
              <p class="text-gray-600 mb-4 whitespace-pre-wrap">
                {article.content}
              </p>
              <div class="text-sm text-gray-500">
                Created: {new Date(article.createdAt).toLocaleDateString()}
                {#if article.updatedAt !== article.createdAt}
                  (Updated: {new Date(article.updatedAt).toLocaleDateString()})
                {/if}
              </div>
            </div>
          {/if}
        </div>
      {:else}
        <p class="text-center text-gray-500 py-8">
          No articles yet. Create your first one!
        </p>
      {/each}
    </div>

記事一覧部分では:

  1. {#each}ブロックで記事のリストをループ
  2. 編集中の記事の場合は編集フォームを表示
    • 記事IDを隠しフィールドで送信
    • タイトルと内容をbind:valueで双方向バインディング
    • キャンセルと保存のボタン
  3. 通常表示モードでは:
    • タイトル、編集・削除ボタン
    • 記事内容
    • 作成日時と更新日時の表示
  4. 記事がない場合は代替メッセージを表示

まとめ

このSvelteKitアプリケーションは、基本的なCRUD操作(作成・読み取り・更新・削除)を実装した記事管理システムです。

+page.server.ts の役割:

  • 認証チェック
  • サーバーサイドでのデータ取得(記事一覧)
  • フォームアクションの処理(作成、更新、削除、ログアウト)
  • データベース操作

+page.svelte の役割:

  • ユーザーインターフェースの提供
  • 状態管理(編集モード、フォーム表示など)
  • フォーム送信のエンハンス(enhanceディレクティブ使用)
  • 条件付きレンダリング(編集モード/表示モードの切り替え)

この実装はSvelteKitのフォーム処理機能を活用し、ページリロードなしで操作を完結させる優れたUXを提供しています。

DEMO: https://mtkwtnb.netlify.app/