[React][Jotai][TypeScript]Jotai


/my-app
│── /src
│      ├── /state              #状態定義フォルダ
│      │      ├── atoms.ts     #Jotai 状態定義
│      ├── /components         #コンポーネントフォルダ
│      │      ├── Search.tsx   #コンポーネント1
│      │      ├── Info.tsx     #コンポーネント2
│      ├── /App.tsx            #アプリのエントリーポイント
│      ├── /main.tsx           #React のルート
│── package.json
│── tsconfig.json
│── index.html

 
state/atoms.ts

import { atom } from "jotai";

// グローバルな状態(count)を定義
export const countAtom = atom(0);

 
components/Search.tsx


import { useAtom } from "jotai";
import { countAtom } from "../state/atoms";

const Search = () => {
    const [count] = useAtom(countAtom);

    return (
<div id="search" className="p-2.5 mb-2.5 bg-white">
    <div>count:{count}</div>
</div>
    );
};

export default Search;

 
components/Info.tsx

import { useAtom } from "jotai";
import { countAtom } from "../state/atoms";

const Info = () => {
    const [count, setCount] = useAtom(countAtom); // グローバル状態を取得&更新

    return (
<div id="info" className="p-2.5 mb-2.5 bg-white">
<div>
    <h2>Count: {count}</h2>
    <button onClick={() => setCount((prev) => prev + 1)}>Increment</button>
</div>
</div>
    );
};

export default Info;

 
App.tsx

import { Provider } from "jotai";
import Search from './components/Search'
import Info from './components/Info'

const App = () => (
  <Provider>
    <Search />
    <Info />
  </Provider>
);

export default App;

 
main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'

createRoot(document.getElementById("app")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);

 
<Provider>で囲われたアプリ、コンポーネント間でステートが共有される

[WordPress]RestAPI#2 エンドポイント追加

functions.php

/**
 * RestAPIエンドポイント追加
 */
function custom_register_rest_routes() {
    /* カテゴリ一覧 */
    register_rest_route('custom/v1', '/categories/', [
        'methods'  => 'GET',
        'callback' => 'custom_callback_categories',
        'permission_callback' => '__return_true' // 認証不要
    ]);
}
add_action('rest_api_init', 'custom_register_rest_routes');

/**
 * カテゴリ一覧取得
 *
 * @return [type]
 * 
 */
function custom_callback_categories($request) {
    $wpt = new KtyrReport2();
    return new WP_REST_Response([
        'terms' => $wpt->get_terms(),
    ], 200);
}

[React][TypeScript]生のhtmlを返す

npm install dompurify

 

import DOMPurify from "dompurify";

const App= () => {
    const rawHtml = `
    <div>
      <h1 style="color: blue;">Hello, World!</h1>
      <p>これは複数行のHTMLです。</p>
      <ul>
        <li>リスト1</li>
        <li>リスト2</li>
      </ul>
    </div>
    `;
    const cleanHtml = DOMPurify.sanitize(rawHtml); //安全なhtmlへ変換
    return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />;
};

export default App;

 
dangerouslySetInnerHTMLを使う場合Fragment (<> </>) は使えない

[Vite][TypeScript]出力ファイル名を指定

vite.config.tsのdefineConfigに下記追記

build: {
  rollupOptions: {
    output: {
      entryFileNames: 'assets/[name].js', // エントリーポイントのファイル名
      chunkFileNames: 'assets/[name].js', // チャンクファイル名
      assetFileNames: 'assets/[name].[ext]', // アセットファイル名
    },
  },
},

[TailwindCSS]@apply

@applyでTailwindCSSのユーティリティクラスをそのまま使える
css

@import "tailwindcss";
article .contents a{
  @apply border-b-1 border-dotted border-gray-400 hover:border-gray-800;
}

 
@layer componentsを使うと、TailwindCSSのコンポーネントスタイルとして追加できる。
css

@import "tailwindcss";
@layer components {
  .btn-custom {
    @apply bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition;
  }
}

 
html

<button className="btn-custom">カスタムボタン</button>

[WordPress][TypeScript]RestAPI #1

functions.php
cf_cat_image:画像タイプのカスタムフィールド
cf_cat_disabled:booleanタイプのカスタムフィールド
/wp-json/wp/v2/posts/のデータにカテゴリの詳細情報を追加する

function add_category_details_to_posts($data, $post, $context) {
    $categories = get_the_category($post->ID);
    $category_details = [];

    foreach ($categories as $category) {
        $category_meta = get_term_meta($category->term_id); // カスタムフィールド取得
        $image_id = get_term_meta($category->term_id, 'cf_cat_image', true);
        $category_details[] = [
            'id' => $category->term_id,
            'name' => $category->name,
            'slug' => $category->slug,
            'cf_cat_image' => empty($image_id) ? '' : wp_get_attachment_url($image_id),
            'cf_cat_disabled' => get_term_meta($category->term_id, 'cf_cat_disabled', true),
        ];
    }
    
    $data->data['category_details'] = $category_details;
    return $data;
}
add_filter('rest_prepare_post', 'add_category_details_to_posts', 10, 3);

 
WordPressPosts.tsx

import { useEffect, useState } from 'react';

// 投稿データの型定義
interface CategoryDetail {
  id: number;
  name: string;
  slug: string;
  cf_cat_image: string;
  cf_cat_disabled: "0" | "1"; // 無効フラグが "0" または "1" の文字列
}
interface Post {
  id: number;
  date: string;
  title: { rendered: string };
  content: { rendered: string };
  excerpt: { rendered: string };
  link: string;
  category_details : CategoryDetail[];
}

const WordPressPosts = () => {
  const [posts, setPosts] = useState<Post[]>([]); // 投稿データ
  const [loading, setLoading] = useState<boolean>(true); // ローディング状態
  const [error, setError] = useState<string | null>(null); // エラーメッセージ

  useEffect(() => {
    const fetchPosts = async () => {
      try {
        const response = await fetch('/wp-json/wp/v2/posts/');
        if (!response.ok) {
          throw new Error('投稿データの取得に失敗しました');
        }
        const data: Post[] = await response.json();
        setPosts(data); // データをステートに保存
      } catch (error) {
        setError(error instanceof Error ? error.message : '不明なエラー');
      } finally {
        setLoading(false); // ローディング終了
      }
    };

    fetchPosts();
  }, []); // 初回レンダリング時のみ実行

  // ローディング中の表示
  if (loading) {
    return <div>Loading...</div>;
  }

  // エラー発生時の表示
  if (error) {
    return <div>Error: {error}</div>;
  }

  return (
    <>
    {posts.map(post => (
      <article className="flex">
        <div className="w-7">
          {post.category_details.map(category_detail => (
            <a className="block" href={"/?c="+category_detail.id}><img src={category_detail.cf_cat_image} /></a>
          ))}
        </div>
        <div className="flex-1">
          <h2 className="entry-title">
            <a href={post.link}>{post.title.rendered}</a>
          </h2>
          <div className="relative">
            <ul className="flex">
              {post.category_details.map(category_detail => (
                <li className=""><a href={"/?c="+category_detail.id}>{category_detail.name}</a></li>
              ))}
            </ul>
            <time className="absolute top-0 right-0" dateTime="">{new Date(post.date).toLocaleDateString("ja-JP", {year: "numeric",month: "2-digit",day: "2-digit"}).replace(/\//g, '-')}</time>
          </div>
          <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
        </div>
      </article>
    ))}
    </>
  );
};

export default WordPressPosts;

 
main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import WordPressPosts from './WordPressPosts.tsx'

createRoot(document.getElementById('main')!).render(
  <StrictMode>
    <WordPressPosts />
  </StrictMode>,
)

 
下記のようなReact.FC使用は最近は非推奨

const WordPressPosts: React.FC = () => {

[React]dangerouslySetInnerHTML

dangerouslySetInnerHTMLを使うと、生のHTMLを埋め込むことができる
サンプル

<div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />

[Laravel Sail]Gmailを使ってメール送信

Googleアカウント設定画面で「アプリパスワード」で検索
アプリ名を入力して作成後、生成したアプリパスワードをMAIL_PASSWORDへ設定
 
.env

MAIL_MAILER=smtp
MAIL_SCHEME=smtp
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=sample@gmail.com
MAIL_PASSWORD='xxxx xxxx xxxx xxxx'

 
メール送信処理は下記
[Laravel Sail]メール送信備忘録
 
Mailpitを利用する場合は不要
Mailpit利用時のメールの確認は下記
http://localhost:8025/

[TypeScript][ChatGPT]interfaceを定義するプロンプト

typescriptのinterfaceの定義 対象は下記
"category_details": [
{
"id": 15,
"name": "PHP",
"slug": "php",
"cf_cat_image": "/uploads/icon-15.png",
"cf_cat_disabled": "0"
},
{
"id": 68,
"name": "Laravel",
"slug": "laravel",
"cf_cat_image": "/uploads/20210311100757.png",
"cf_cat_disabled": "0"
}
],