定年後にWeb開発者目指す https://software.pitang1965.com 2019年1月から学習開始 Mon, 26 Sep 2022 07:06:07 +0000 ja hourly 1 https://wordpress.org/?v=6.0.2 https://software.pitang1965.com/wp-content/uploads/2021/06/cropped-yama512-32x32.png 定年後にWeb開発者目指す https://software.pitang1965.com 32 32 GraphQLからTypeScriptの型定義を生成 https://software.pitang1965.com/2022/09/26/how-to-generate-typescript-type-definition-from-graphql/ https://software.pitang1965.com/2022/09/26/how-to-generate-typescript-type-definition-from-graphql/#respond Mon, 26 Sep 2022 07:00:42 +0000 https://software.pitang1965.com/?p=4227 概要

@apollo/client などの一般的なGraphQLクライアントを用いてGraphQL APIからデータを取得するTypeScriptのコードで用いる型定義を、GraphQLサーバーから取得する方法のメモです。

インストール

以下をローカルにインストールします。pnpmの場合で説明。

$ pnpm add -D graphql
$ pnpm add -D @graphql-codegen/cli @graphql-codegen/typescript

設定ファイル

codegen.yaml を次のように設定します。

overwrite: true
schema: "http://hot-dang-homes-course.local/graphql"
generates:
  src/generated/graphql.tsx:
    plugins:
      - "typescript"

GraphQL Code Generator CLIから設定ファイルの元を生成することも可能(詳細はこちら)ですが、生成された設定ファイルで動くとは限りません(余計な設定が追加されてしまう)。

package.jsonに次の1行を追加します。

    "scripts": {
        "dev": "next dev",
        "build": "next build",
        "start": "next start",
        "lint": "next lint",
        "generate": "graphql-codegen --config codegen.yml" <--- ★これ
    },

型定義ファイルの生成

上記の設定の場合、generateスクリプトを実行することにより、src/generated/graphql.tsx が生成されます。

型の利用

例えば次のように利用できます。

type Props = {
  blocks: any;
};

↓

import { Block } from 'src/generated/graphql';

type Props = {
  blocks: Block[];
};

おまけ:WPGraphQL

WPGraphQL General SettingsEnable Public Introspection をチェックつけないとエラーになるようです。

]]>
https://software.pitang1965.com/2022/09/26/how-to-generate-typescript-type-definition-from-graphql/feed/ 0
Next.jsからGitHub GraphQL APIを使用 https://software.pitang1965.com/2022/09/20/how-to-use-github-graphql-api-in-nextjs/ https://software.pitang1965.com/2022/09/20/how-to-use-github-graphql-api-in-nextjs/#respond Tue, 20 Sep 2022 02:02:58 +0000 https://software.pitang1965.com/?p=4222 概要

Next.js/TypeScriptのプロジェクトから、GitHubのGraphQL APIにアクセスする方法を簡単にメモします。Next.jsのapiルートからGraphQL Yogaとgraphql-requestを用い方法になります。

詳細は以下を参照。

GraphQLのエクスプローラ

https://docs.github.com/ja/graphql/overview/explorer にて、GitHubアカウントでサインインした上で、次のクエリを入力します。

query {
  viewer {
    login
  }
}

これで次の結果が得られます。

{
  "data": {
    "viewer": {
      "login": "pitang1965"
    }
  }
}

今回は次のクエリを用います。

query MyQuery {
  viewer {
    name
    repositories(last: 5) {
      nodes {
        name
        description
      }
    }
  }
}

このクエリを実行すると先ほどの結果に加えて、5つのリポジトリの名前と説明が出力されます。

インストール

npm i @graphql-yoga/node graphql graphql-request
又は
yarn add @graphql-yoga/node graphql graphql-request
又は
pnpm i @graphql-yoga/node graphql graphql-request

環境変数の設定

GitHub APIのトークンは、GitHubのサイトのSettings → Developer settings → Personal access tokensから取得できます。

// .env.local
GITHUB_BEARER_TOKEN=xxxx

src/pages/api/github.ts

Next.jsのapiルートからgraphql-requestを用いてGitHub GraphQL APIにアクセスし、その結果をGraphQLサーバーであるGraphQL Yogaを使って/api/github として提供します。

import { GraphQLClient, gql } from 'graphql-request';
import { createServer } from '@graphql-yoga/node';

// GitHubからデータを取得

const resolvers = {
  Query: {
    async repositories() {
      const githubEndPoint = 'https://api.github.com/graphql';

      const query = gql`
        {
          viewer {
            name
            repositories(last: 5) {
              nodes {
                name
                description
              }
            }
          }
        }
      `;

      const graphQLClient = new GraphQLClient(githubEndPoint, {
        headers: {
          authorization: `Bearer ${process.env.GITHUB_BEARER_TOKEN}`,
        },
      });

      const data = await graphQLClient.request(query);
      // console.log(JSON.stringify(data.viewer.repositories.nodes, undefined, 2));
      return data.viewer.repositories.nodes;
    },
  },
};

// APIとして値を返す

const typeDefs = /* GraphQL */ `
  type Query {
    repositories: [Repository!]!
  }
  type Repository {
    name: String
    description: String
  }
`;

const server = createServer({
  schema: {
    typeDefs,
    resolvers,
  },
  endpoint: '/api/github',
  // graphiql: false // uncomment to disable GraphiQL
});

export default server;

クライアント側のコード

以下はuseSWRを用いて、/api/githubにアクセスし、5つのリポジトリの名前と説明を表示するコードです。

import useSWR from 'swr';

const fetcher = (query: string) =>
  fetch('/api/github', {
    method: 'POST',
    headers: {
      'Content-type': 'application/json',
    },
    body: JSON.stringify({ query }),
  })
    .then((res) => res.json())
    .then((json) => json.data);

export default function Index() {
  const { data, error } = useSWR(
    `{
      repositories {
        name
        description
      }
    }
    `,
    fetcher
  );

  console.log(data);

  if (error) return <div>Failed to load</div>;
  if (!data) return <div>Loading...</div>;

  const { repositories } = data;

  return (
    <div>
      {repositories.map((repository: any, i: number) => (
        <div key={i}>
          <h1>{repository.name}</h1>
          <p>{repository.description}</p>
        </div>
      ))}
    </div>
  );
}

出力イメージは次です。

pitang-todo
Vite + React + TypeScipt + Supabase による簡単なtodoアプリ

gudid-search
openFDAを用いて企業名からUDI(機器固有識別子)の一覧を得て表で表示。

git_training
next-portfolio
各種APIを用いた自己紹介サイト

qin-team-dev
qinサロンチーム開発
]]>
https://software.pitang1965.com/2022/09/20/how-to-use-github-graphql-api-in-nextjs/feed/ 0
Access to fetch at ‘https://xxx from origin ‘https://yyy has been blocked by CORS policy https://software.pitang1965.com/2022/09/11/access-to-fetch-at-https-xxx-from-origin-https-yyy-has-been-blocked-by-cors-policy/ https://software.pitang1965.com/2022/09/11/access-to-fetch-at-https-xxx-from-origin-https-yyy-has-been-blocked-by-cors-policy/#respond Sun, 11 Sep 2022 04:31:42 +0000 https://software.pitang1965.com/?p=4213 Next.jsapiルートで作ったAPIを、StackBlitz等の他のドメインから使用しようとしたときに、次のようなエラーが出ました。

Access to fetch at 'https://pitang1965-next-portfolio.vercel.app//api/tweet' from origin 'https://vitejs-vite-hxwvfs--5173.local.webcontainer.io' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.

これを修正するには、APIのほうをなんとかしないといけないようです。

次でできました。

インストール

nextjs-corsをインストールします。

$ npm install nextjs-cors

又は

$ yarn add nextjs-cors

又は

$ pnpm install nextjs-cors

apiルートのコード変更

:

import NextCors from 'nextjs-cors';   <-- ★追加

export default async function twitter (
  req: NextApiRequest,
  res: NextApiResponse
) {
    await NextCors(req, res, {   <-- ★追加
      methods: ['GET'],
      origin: '*',
      optionSuccessStatus: 200,
    });
    :
]]>
https://software.pitang1965.com/2022/09/11/access-to-fetch-at-https-xxx-from-origin-https-yyy-has-been-blocked-by-cors-policy/feed/ 0
CSS Modulesで複数クラスを指定する方法 https://software.pitang1965.com/2022/08/12/how-to-specify-multiple-classes-in-css-modules/ https://software.pitang1965.com/2022/08/12/how-to-specify-multiple-classes-in-css-modules/#respond Fri, 12 Aug 2022 13:15:12 +0000 https://software.pitang1965.com/?p=4196 概要

Next.js では何も設定せずにコンポーネントレベルでCSSを使用できる CSS Modules が使用できます。つまり、コンポーネント間でのクラス名の重複など気にしないことができます。Tailwind CSS が気に入らない場合は、候補になりうると思います。使い方は簡単です。

ルール

  1. xxx.module.css というファイル名を使用する。例:Button.module.css
  2. 次のように利用する。
import styles from './Button.module.css'

export function Button() {
  return (
    <button
      type="button"
      className={styles.button} ← ★Button.module.css に .button { ... } があること。

ただし、次のように複数のクラス名やハイフン付きを指定できません。

className={ styles.button styles.button-darkmode }

解決方法

classnames を使用します。

$ npm i classnames

又は

$ yarn add classnames

又は

$ pnpm i classnames

次のようにします。少し変な書き方ですが、ハイフンの問題も解決します。

:

import cx from 'classnames';

:

<Container
  className={cx(styles.container, 
    styles['mobile-layout'])}
]]>
https://software.pitang1965.com/2022/08/12/how-to-specify-multiple-classes-in-css-modules/feed/ 0
Supabaseに入門しました https://software.pitang1965.com/2022/07/28/supabase-getting-started/ https://software.pitang1965.com/2022/07/28/supabase-getting-started/#respond Thu, 28 Jul 2022 12:03:54 +0000 https://software.pitang1965.com/?p=4130 概要

オープンソースでFirebaseの代替として人気のあるsupabaseをちょっと試してみました(チュートリアル+α)。

機能として以下があります。

  1. データベース (Postgres)
  2. 認証
  3. ストレージ
  4. エッジファクション

個人開発にはとても良さそうです。ただ無料プランだと1週間使わないと停止するそうです(どういうことかよくわかっていない。料金プラン参照)。

認証(パスワードレスのマジックリンク)付きでデータベース、プロフィール画像はストレージに保存する次のようなtodoアプリを作成しました。

作成したto-doアプリ

開発環境の構築には、ViteReactTypeScriptを採用しました。

サインイン及びプロジェクトの開始

[Sign In with GitHub]ボタンから簡単におこなえます。

データベーススキーマの設定

2種類の方法にて、todosテーブルとprofilesテーブルを生成します。まずは、todosテーブルを作成します。

  1. supabaseのダッシュボードのアイコンの並びからSQL Editorセクションに移動する。
  2. Quick startTodo Listというスターターがあるのでクリックする。
  3. [Run]ボタンをクリックする。

この画面には次のデータベーススキーマ(データベースの構造)を設定する次のようなSQLが表示されています。

create table todos (
  id bigint generated by default as identity primary key,
  user_id uuid references auth.users not null,
  task text check (char_length(task) > 3),
  is_complete boolean default false,
  inserted_at timestamp with time zone default timezone('utc'::text, now()) not null
);
alter table todos enable row level security;
create policy "Individuals can create todos." on todos for
    insert with check (auth.uid() = user_id);
create policy "Individuals can view their own todos. " on todos for
    select using (auth.uid() = user_id);
create policy "Individuals can update their own todos." on todos for
    update using (auth.uid() = user_id);
create policy "Individuals can delete their own todos." on todos for
    delete using (auth.uid() = user_id);
  • ここで前半の create table のブロックでは新しいテーブルtodosを定義しています。
  • alter table todos enable row level security は、テーブル定義をRLS(Row Level Security、行単位のセキュリティ)を有効に変更しています。
    • RLSPostgresのポリシーを書くことによってテーブルへのアクセスを制限します。
    • RLSが有効でない場合は、anonキーを持つ誰もがあなたのデータを変更及び削除することができます。
  • create policy は、テーブルに新しい行単位のセキュリティポリシーを定義します。

続いてprofilesテーブルを生成します。

  1. supabaseのダッシュボードのアイコンの並びからSQL Editorセクションに移動する。
  2. [+ New query]ボタンをクリックする。
  3. 右の編集画面に下記SQLを貼り付けて[Run]ボタンをクリックする。
  4. サイドバーに示されているNew Queryは不要なので右クリックメニューの[Remove query]で削除する。
-- Create a table for public "profiles"
create table profiles (
  id uuid references auth.users not null,
  updated_at timestamp with time zone,
  username text unique,
  avatar_url text,
  website text,

  primary key (id),
  unique(username),
  constraint username_length check (char_length(username) >= 3)
);

alter table profiles enable row level security;

create policy "Public profiles are viewable by everyone."
  on profiles for select
  using ( true );

create policy "Users can insert their own profile."
  on profiles for insert
  with check ( auth.uid() = id );

create policy "Users can update own profile."
  on profiles for update
  using ( auth.uid() = id );

-- Set up Realtime!
begin;
  drop publication if exists supabase_realtime;
  create publication supabase_realtime;
commit;
alter publication supabase_realtime add table profiles;

-- Set up Storage!
insert into storage.buckets (id, name)
values ('avatars', 'avatars');

create policy "Avatar images are publicly accessible."
  on storage.objects for select
  using ( bucket_id = 'avatars' );

create policy "Anyone can upload an avatar."
  on storage.objects for insert
  with check ( bucket_id = 'avatars' );

これで2つのテーブルが作成されていることが、supabaseのダッシュボードのアイコンの並びからDatabaseをクリックすると確認できます。

Database

APIキーの取得

  1. supabaseのダッシュボードのアイコンの並びからSettingsセクションに移動する。
  2. サイドバーのAPIをクッリクする。
  3. 以下などが確認できます。
    • Project URL: データベースのクエリ及び管理のためのRESTfulエンドポイント
    • Project API keys: APIAPIゲートウェイで保護されていて各リクエストでAPIキーが必要。これらのキーはSupabace クライアントライブラリで使用可能。
      • anon:ブラウザで安全に使用可能なキー
      • service_role: 秘密にしておく必要があるキー

Reactアプリの構築

今回はVite (react-ts)を使います。

$ yarn create vite pitang-todo --template react-ts
$ cd pitang-todo
$ yarn add @supabase/supabase-js
$ yarn add --dev @types/node   // process.envに対してのTypeScriptエラーなどを消す
$ code .

環境変数

ルートフォルダに、.envファイルを作成し、手前の手順のProject URLanonの値を設定します。

VITE_SUPABASE_URL=YOUR_SUPABASE_URL
VITE_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

Supabaseクライアント初期化ファイル

Supabaseクライアントを初期化するためのヘルパーファイルを作成します。これらの環境変数はブラウザに晒されても大丈夫とのことです。

// src\utils\supabaseClient.ts

import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseAnonKey);

Notistackの使用

今回はalertの変わりにNotistackという通知ライブラリを使用しています。次でインストールします。

$ yarn add notistack @mui/material @emotion/react @emotion/styled

使い方の概要はAppコンポーネントの中身を<SnackbarProvider>で囲って、各コンポーネントでは次のようにしてalertを置き換えてメッセージを出します。

:
import { useSnackbar } from 'notistack';
:
  const { enqueueSnackbar } = useSnackbar();
:
  enqueueSnackbar('リンクのためのメールをご確認ください。', {
    variant: 'info',
  });

Accountコンポーネント

ユーザーがログインしたあと、プロフィールの詳細を編集し、アカウントを管理できるようにAccountコンポーネントを追加します。

このコードは、公式ドキュメントのQuickstart: ReactBonus: Profile photosも組み込んだもの)をベースにTypeScript化し若干変更したものに対し、Todos コンポーネントを埋め込んだものになります。

// src\components\Account.tsx

import { useState, useEffect } from 'react';
import { supabase } from '../utils/supabaseClient';
import { Session } from '@supabase/gotrue-js';
import { useSnackbar } from 'notistack';
import type { SnackbarMessage } from 'notistack';
import Avatar from './Avatar';
import Todos from './Todos';

type Props = {
  session: Session;
};

export default function Account({ session }: Props) {
  const [loading, setLoading] = useState(true);
  const [username, setUsername] = useState<any>(null);
  const [website, setWebsite] = useState<any>(null);
  const [avatar_url, setAvatarUrl] = useState<any>(null);
  const { enqueueSnackbar } = useSnackbar();

  useEffect(() => { // ★ ログインしたら getProfile()を呼ぶ
    getProfile();
  }, [session]);

  async function getProfile() {
    try {
      setLoading(true);
      const user = supabase.auth.user(); // ★ ログインしているユーザーを取得

      const { data, error, status } = await supabase // ★ 当該ユーザーの profiles を取得
        .from('profiles')
        .select(`username, website, avatar_url`)
        .eq('id', user?.id)
        .single();

      if (error && status !== 406) {
        throw error;
      }

      if (data) { // ★ ユーザー名、Webサイト、アバタURLを設定
        setUsername(data.username);
        setWebsite(data.website);
        setAvatarUrl(data.avatar_url);
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setLoading(false);
    }
  }

  async function updateProfile({ // ★ 当該ユーザーの profiles を更新
    username,
    website,
    avatar_url,
  }: {
    username: any;
    website: any;
    avatar_url: any;
  }) {
    try {
      setLoading(true);
      const user = supabase.auth.user();

      const updates = {
        id: user?.id,
        username,
        website,
        avatar_url,
        updated_at: new Date(),
      };

      let { error } = await supabase.from('profiles').upsert(updates, {
        returning: 'minimal', // Don't return the value after inserting
      });

      if (error) {
        throw error;
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className='form-widget'>
      <h1>{session?.user?.email}</h1>
      <Avatar // ★ アバター
        url={avatar_url}
        size={150}
        onUpload={(url: any) => {
          setAvatarUrl(url);
          updateProfile({ username, website, avatar_url: url });
        }}
      />

      <div className='flex'>
        <label htmlFor='username' className='w-28'> // ★ 名前
          名前
        </label>
        <input
          id='username'
          type='text'
          value={username || ''}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      <div className='flex'>
        <label htmlFor='website' className='w-28'> // ★ ウェブサイト
          ウェブサイト
        </label>
        <input
          id='website'
          type='website'
          value={website || ''}
          onChange={(e) => setWebsite(e.target.value)}
        />
      </div>

      <div className='flex'>
        <button // ★ [更新]ボタン
          className='button primary'
          onClick={() => updateProfile({ username, website, avatar_url })}
          disabled={loading}
        >
          {loading ? '読み込み中...' : '更新'}
        </button>
        <button className='button' onClick={() => supabase.auth.signOut()}> // ★ [ログアウト]ボタン
          ログアウト
        </button>
      </div>
      <Todos session={session} />
    </div>
  );
}

Avatarコンポーネント

Supabaseプロジェクトには、写真や動画といった大容量ファイルを管理するためのStorageという機能があります。チュートリアルにそって、プロフィール写真を管理・表示できるようにします。ここでAvatarコンポーネントを作りますが、以下のバツを付けたところを非表示にするために、@reach/visually-hiddenを使用します。

<input type=”file”>を非表示にしたい
$ yarn add @reach/visually-hidden
// src\components\Avatar.tsx

import { useEffect, useState } from 'react';
import { supabase } from '../utils/supabaseClient';
import VisuallyHidden from '@reach/visually-hidden';
import { useSnackbar } from 'notistack';
import type { SnackbarMessage } from 'notistack';

export default function Avatar({
  url,
  size,
  onUpload, // ★ 親のAccountの関数
}: {
  url: any;
  size: any;
  onUpload: any;
}) {
  const [avatarUrl, setAvatarUrl] = useState<any>(null);
  const [uploading, setUploading] = useState(false);
  const { enqueueSnackbar } = useSnackbar();

  useEffect(() => {
    if (url) downloadImage(url); // ★ 画像URLが変わったら実行
  }, [url]);

  const downloadImage = async (path: any) => {
    try {
      const { data, error } = await supabase.storage // ★画像をsupabaseからダウンロード
        .from('avatars')
        .download(path);
      if (error) {
        throw error;
      }
      if (typeof data === 'object') { // ★引数で指定されたオブジェクトを表すURLを含むDOMStringを生成
        const url = URL.createObjectURL(data as Blob);  
        setAvatarUrl(url);
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    }
  };

  const uploadAvatar = async (event: any) => { // ★ ファイル選択後の処理
    try {
      setUploading(true);

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('アップロードする画像を選択してください。');
      }

      const file = event.target.files[0];
      const fileExt = file.name.split('.').pop();
      const fileName = `${Math.random()}.${fileExt}`;
      const filePath = `${fileName}`;

      let { error: uploadError } = await supabase.storage // ★ ファイルをsupabaseにアップロード
        .from('avatars')
        .upload(filePath, file);

      if (uploadError) {
        throw uploadError;
      }

      onUpload(filePath); // ★ 親のAccountのほうで画像URLを更新する
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setUploading(false);
    }
  };

  return (
    <div style={{ width: size }} aria-live='polite'> // ★ 要素が更新されることを適切なタイミングで示す
      <img
        src={avatarUrl ? avatarUrl : `https://place-hold.it/${size}x${size}`}
        alt={avatarUrl ? 'プロフィール画像' : '画像なし'}
        className='avatar image'
        style={{ height: size, width: size }}
      />
      {uploading ? (
        'アップロード中...'
      ) : (
        <>
          <label className='button primary block' htmlFor='single'> // ★ ファイル選択ダイアログを表示
            画像アップロード
          </label>
          <VisuallyHidden> // ★ 次を非表示にする
            <input // ★ ファイル選択ダイアログ
              type='file'
              id='single'
              accept='image/*'
              onChange={uploadAvatar}
              disabled={uploading}
            />
          </VisuallyHidden>
        </>
      )}
    </div>
  );
}

Todosコンポーネント

// src\components\Todos.tsx

import { useState, useEffect } from 'react';
import { supabase } from '../utils/supabaseClient';
import { Session } from '@supabase/gotrue-js';
import { useSnackbar } from 'notistack';
import type { SnackbarMessage } from 'notistack';
import TodoCard from './TodoCard';
import type { Todo } from './TodoCard';

type Props = {
  session: Session;
};

export default function Todos({ session }: Props) {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTask, setNewTask] = useState('');
  const { enqueueSnackbar } = useSnackbar();

  useEffect(() => {
    getTodos(); // ★ 初期化
  }, [session]);

  async function deleteTask(id: number) { // ★ To-doタスク削除処理
    if (!session) {
      return;
    }
    try {
      const newTodos: Todo[] = todos.filter((todo) => todo.id !== id);

      const { data, error } = await supabase
        .from<Todo>('todos')
        .delete()
        .match({ id: id });

      if (!data && error) {
        throw error;
      }
      setTodos(newTodos);
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } finally {
      setLoading(false);
    }
  }

  async function toggleTaskComplete(id: number) { // ★ To-doタスクの完了状態をトグル
    if (!session) {
      return;
    }
    try {
      let newTodo: Todo | undefined;
      const newTodos: Todo[] = todos.map((todo) => {
        if (todo.id !== id) {
          return todo;
        } else {
          newTodo = todo;
          todo.is_complete = !todo.is_complete;
          return newTodo;
        }
      });
      if (newTodo) {
        const { data, error } = await supabase
          .from<Todo>('todos')
          .update({ is_complete: newTodo.is_complete })
          .match({ id: id });

        if (!data && error) {
          throw error;
        }
        setTodos(newTodos);
      }
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    }
  }

  async function getTodos() { // ★ To-doタスクを読み込む
    if (!session) {
      return;
    }
    try {
      const { data, error } = await supabase
        .from<Todo>('todos')
        .select('*')
        .eq('user_id', session.user!.id)
        .order('inserted_at', { ascending: true });

      if (!data && error) {
        throw error;
      }
      setTodos(data);
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    }
  }

  const addNewTodo = async (newTask: string) => { // ★ To-doタスクの追加処理
    if (!session) {
      return;
    }
    if (newTask.length <= 3) {
      enqueueSnackbar('4文字以上にしてください。', { variant: 'warning' });
      return;
    }
    try {
      const { data, error } = await supabase
        .from<Todo>('todos')
        .insert({ task: newTask, user_id: session.user!.id })
        .single();
      setTodos([...todos, data!]);
      setNewTask('');
    } catch (error) {
      enqueueSnackbar(error as SnackbarMessage, { variant: 'error' });
    } 
  };

  return (
    <>
      <h1>やること</h1>
      <div className='flex'>
        <input
          type='text'
          placeholder='やることを追加してください。'
          value={newTask}
          className='grow'
          onChange={(e) => setNewTask(e.target.value)}
        />

        <button
          className='button primary flex-none'
          onClick={() => addNewTodo(newTask)}
        >
          追加
        </button>
      </div>
      <ol>
        {todos &&
          todos.map((todo) => (
            <TodoCard // ★ 各To-do
              key={todo.id}
              todo={todo}
              onDelete={deleteTask}
              onToggleComplete={toggleTaskComplete}
            />
          ))}
      </ol>
    </>
  );
}

TodoCardコンポーネント

// src\components\TodoCard.tsx

import { VscTrash } from 'react-icons/vsc';

export type Todo = { // ★ 各To-doのデータ
  id: number;
  user_id: string;
  task: string;
  is_complete: boolean;
  inserted_at: Date; // ★ 今は使っていない
};

type Props = {
  todo: Todo;
  onDelete: (id: number) => void;
  onToggleComplete: (id: number) => void;
};

export default function TodoCard({ todo, onDelete, onToggleComplete }: Props) {
  return (
    <li className='card' key={todo.id}>
      <div className='flex'>
        {todo.is_complete ? (
          <div className='grow'>
            <del>{todo.task}</del>{' '}
          </div>
        ) : (
          <div className='grow'>{todo.task} </div>
        )}
        <button // ★ [未完了に戻す] ←→ [完了にする]ボタン
          className='button complete-button flex-none'
          onClick={() => onToggleComplete(todo.id)}
        >
          {todo.is_complete ? '未完了に戻す' : '完了にする'}
        </button>
        <button className='button flex-none' onClick={() => onDelete(todo.id)}> // ★ ゴミ箱アイコン
          <VscTrash />
        </button>
      </div>
    </li>
  );
}

Authコンポーネント

ログインとサインアップをおこなうためのAuthコンポーネントを作成します。マジックリングを使うのでパスワードレスです。

// src\components\Auth.tsx

import { useState } from 'react';
import { supabase } from '../utils/supabaseClient';
import { useSnackbar } from 'notistack';

export default function Auth() {
  const [loading, setLoading] = useState(false);
  const [email, setEmail] = useState('');
  const { enqueueSnackbar } = useSnackbar();

  const handleLogin = async (email: string) => { // ★ マジックリンクを送信する処理
    try {
      setLoading(true);
      const { error } = await supabase.auth.signIn({ email }); // ★ マジックリンク送信
      if (error) throw error;
      enqueueSnackbar('リンクのためのメールをご確認ください。', {
        variant: 'info',
      });
    } catch (error: any) {
      enqueueSnackbar(error.error_description || error.message, {
        variant: 'error',
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className='row flex flex-center'>
      <div className='col-6 form-widget'>
        <h1 className='header'>ピータンTO-DO</h1>
        <p className='description'>下記Eメールでマジックリンクでログイン</p>
        <div>
          <input // ★ Eメール入力
            className='inputField'
            type='email'
            placeholder='Eメール'
            value={email}
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <button // ★ [マジックリンクを送信] ←→ [読み込み中...]ボタン
            onClick={(e) => {
              e.preventDefault();
              handleLogin(email);
            }}
            className='button block'
            disabled={loading}
          >
            <span>{loading ? '読み込み中...' : 'マジックリンクを送信'}</span>
          </button>
        </div>
      </div>
    </div>
  );
}
  • auth.signIn()についてはこちらに記載があります。
  • 電子メール又はこちらに列挙されるサードパーティのOAuthプロバイダでログインが可能。
  • 電子メールでパスワードが与えられない場合は、マジックリンクが送信される(今回のケース)。
  • マジックリンクはデフォルトで、各ユーザーが60秒に1回までしか送れない。
  • マジックリンクでログイン後のURLは、SITE_URLで設定される。これは、SupabaseのダッシュボードのAuthentificationSettingsで設定できる。

App.tsx の変更

AuthコンポーネントとAccountコンポーネントを利用するようにApp.tsxを次のように変更します。

// src\App.tsx

import './App.css';
import { useState, useEffect } from 'react';
import { supabase } from './utils/supabaseClient';
import Auth from './components/Auth';
import Account from './components/Account';
import { Session } from '@supabase/gotrue-js';
import { SnackbarProvider } from 'notistack';

function App() {
  const [session, setSession] = useState<Session | null>(null);

  useEffect(() => {
    setSession(supabase.auth.session()); // ★ セッションデータを返す

    supabase.auth.onAuthStateChange((_event, session) => { // ★ 認証のイベントを受け取る
      setSession(session);
    });
  }, []);

  return (
    <SnackbarProvider>
      <div className='container' style={{ padding: '50px 0 100px 0' }}>
        {!session ? (
          <Auth /> // ★ アクティブなセッションがない場合
        ) : (
          <Account key={session?.user?.id} session={session} /> // ★ アクティブなセッションがある場合
        )}
      </div>
    </SnackbarProvider>
  );
}

export default App;

App.css の編集

こちらからCSSをコピペします。

そこから多少追加変更しました。

// 変更
.flex {
  display: flex;
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
  align-items: center;
  gap: 1rem;
}

// 追加
.complete-button {
  width: 8rem;
}

.w-28 {
  width: 7rem;
}

/* React-Toastify */
/* https://fkhadra.github.io/react-toastify/how-to-style/#override-existing-css-classes */
* {
  --toastify-color-light: red;
}

実行

以下のコマンドを実行し、http://localhost:3000 にアクセスします。

$ yarn dev
認証画面

上記画面でメールアドレスを入力し、[マジックリンクを送信]ボタンをクリックすると noreply@mail.app.supabace.io からメールが送信されてきます。

vite.config.jsの変更

vitev3になり、開発サーバーのデフォルトのポートが30005173に変更されました(詳細)。以前のままにするためには次のようにserver.portを設定します。

// vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
  server: {
    port: 3000,
  },
  plugins: [react()],
});

デプロイ

動作確認するために、GitHubNetlifyなどを利用してデプロイします。

以下の環境変数の設定を忘れずに。

  • VITE_SUPABASE_URL
  • VITE_SUPABASE_ANON_KEY

また、認証のためにデプロイしたサイトのURLをSupabaseに教える必要があります。

  1. supabaseのダッシュボードのアイコンの並びのAutheticationをクリックする。
  2. サイドバーのSettingsをクリックする。
  3. Site URLを”https://localhost:3000“からデプロイ先のURLに変更する。

Supabaseの認証設定

  • AuthenticationSettings
    • User SessionsSite URLにデプロイ先のURLを設定します。
    • Redirect URLsにデプロイ先のURLを設定します。
]]>
https://software.pitang1965.com/2022/07/28/supabase-getting-started/feed/ 0