GIGABYTEは、AMD B850チップセットを搭載した新しいMicroATXマザーボード「B850M DS3H ICE」を発売する。
Views: 0
Azure Functions で MCP サーバーが作れるようになりました!
Build AI agent tools using remote MCP with Azure Functions
桜の時期のお気に入りの散歩コースを歩いて記事を書こうと思ったら、JP さんに先を越されてました…。
JP さんのやっていることと同じにはなりますが、私は Visual Studio 2022 で C# のみでやります。
まずは Azure Functions のプロジェクトを作成します。今回は .NET Aspire の機能も使いたいと思っているので .NET Aspire オーケストレーションへの参加 (プレビュー) をオンにしました。プロジェクト名は McpAzureFunctions
という名前で作成しました。この後のコード例は、このプロジェクトで作成した前提のコードになります。次に以下の NuGet パッケージをインストールします。
そして天気予報を返してくれる関数を作成します。以下のようなコードにしました。
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Mcp;
using System.ComponentModel;
namespace McpAzureFunctions;
public class MyMcpTools
{
[Function(nameof(GetWeatherForecast))]
public WeatherForecastResult GetWeatherForecast(
[McpToolTrigger(nameof(GetWeatherForecast), "指定された場所の天気予報を取得します。")]
ToolInvocationContext context,
[McpToolProperty(nameof(location), "string", "天気を知りたい場所の名前")]
string location)
{
return location switch
{
"東京" => new WeatherForecastResult("東京", "晴れ"),
"大阪" => new WeatherForecastResult("大阪", "曇り"),
"福岡" => new WeatherForecastResult("福岡", "雨"),
_ => new WeatherForecastResult(location, "不明"),
};
}
}
public record WeatherForecastResult(
string Location,
string Weather);
これで実行して Azure Functions のプロジェクトの標準出力を確認すると、以下のような出力がされます。
[2025-04-05T08:05:20.358Z] Worker process started and initialized.
Functions:
GetWeatherForecast: mcpToolTrigger
ちゃんと mcpToolTrigger で GetWeatherForecast が登録されているのがわかります。
この MCP ツールを試してみましょう。AppHost プロジェクトの Program.cs
に以下のコードを追加して @modelcontextprotocol/inspector
を実行するようにします。
Program.cs
builder.AddExecutable(
"mcp-inspector",
"npx",
".",
"@modelcontextprotocol/inspector",
"node",
"build/index.js")
.WithHttpEndpoint(8080,6274);
実行すると以下のように .NET Aspire のダッシュボードに mcp-inspector が表示されます。
mcp-inspector の部分に表示されているエンドポイントを選択すると以下のような画面が出るので、SSE を選んで http://localhost:7173/runtime/webhooks/mcp/sse
を入力して Connect を押します。
そして List Tools を選択してツールを取得すると、以下のように GetWeatherForecast ツールが取得できます。そしてパラメーターを入れて実行をして結果を確認することが出来ます。
Azure にデプロイして VS Code で接続したらタイムアウトになって動かなかったので、後で確認しようと思います。現時点では私は動かせていません。
Azure Functions で MCP サーバーが作れるようになりました。プレビューですが、一般提供開始になるとお手軽に MCP サーバーが作れるようになると思います。認証とかもサービス側で設定するだけで出来るようになるので、そこらへんも楽なポイントだと思います。
個人的には Azure Functions の 230 秒の HTTP リクエストのタイムアウトって大丈夫なんだろうかというのが心配ではありますが、そこら辺は実際に Azure にデプロイできたら確認してみようと思います。
今回、試しに .NET Aspire と組み合わせて Model Context Protocol inspector を一緒に起動してみました。MCP サーバーの開発をする際には仕込んでおくと簡単に動作確認が出来るので楽だと思います。
ちゃんとテストするなら .NET Aspire のテスト機能でサーバーを実際に実行させて MCP クライアント系のライブラリで繋いでテストすることになるのかなと思います。
Views: 0
私はリー・ロビンソンのテイクを楽しんだ 2024年にCSSを書いています。ツールや構文に飛び込むのではなく、ユーザーから始まります。
ウェブサイトにアクセスするとき、スタイルシートを読み込むような素晴らしい経験は何ですか?
- スタイルシートはできるだけ早くロードする必要があります(小さなファイルサイズ)
- StyleSheetsが変更されない限り再ダウンロードしないでください(適切なキャッシュヘッダー)
- ページのコンテンツには、レイアウトシフトが最小か、または不要な必要があります
- フォントはできるだけ速くロードし、レイアウトシフトを最小限に抑える必要があります
同意した! Number 3、およびある程度4は、CSSよりもJavaScriptバケットでほぼ多くなっていますが、優れたスターターリストです。 「ページスタイルはデフォルトのアクセシビリティを妨害しないでください」を追加します。
その後、開発者エクスペリエンスが考慮されます。
使用するスタイリングツールのDXは、より良いUXを作成するのにどのように役立ちますか?
- 未使用のスタイルを剪定し、CSSを小さいファイルサイズに合わせてプルン
- 安全で不変のキャッシングを有効にするために、ハッシュされたファイル名を生成します
- CSSファイルをバンドルして、ネットワークリクエストを少なくします
- 視覚的回帰を避けるために、衝突の命名を防ぎます
より維持可能で楽しいCSSを書くのに役立つのはどうですか?
- 対応するUIコードを削除するときにスタイルを簡単に削除できます
- 設計システムまたはテーマのセットを簡単に遵守できます
- タイプスクリプトサポート、オートコンプリート、糸くずを使用したエディターフィードバック
- 編集者内でツーリングフィードバックを受け取り、エラーを防ぐ(タイプチェック、糸くず)
DXの懸念は、UXが要求するものを容易にすることについての懸念が好きです。私はそのすべてが欲しいです!私はまだ対処するというアイデアに毛を 未使用のスタイル。その とても 未使用のスタイルを適切に検出するのは難しく、これらの決定を下すツールについて心配しています。
Leeの究極の推奨事項は、CSSモジュール、Tailwind、またはStylex(または単純なもののバニラCSS)です。私は、彼自身の旅に基づいて公平だと感じ、彼がレイアウトしたことを成し遂げます。私はCSSモジュールのファンです。それは主にバニラCSSですが、優れたスコーピングが組み込まれているため、コンポーネントをうまくカップルし、非常によく確立されており、必要な場所にあります。
現実の世界でCSSを書くといえば、アフマドシェードは TechCrunchレイアウトを見るのはかなり深いダイビング そして、現代のテクニックでそれに近づきます。
確かに、それは3列のレイアウトですが、異なる列にはあらゆる種類の異なる制約があります。 1つは固定位置にあり、メインコンテンツは最大幅ですが、それ以外の場合は流動的であり、ネストされたグリッドが含まれています。全体で最大幅もあり、3番目の列には絶対的な位置が含まれます。それは(5つ!)主要なブレークポイントとフッターの複雑さに入ることはありません。 CSSレイアウトを求めている場合、Ahmadは文字通り5つの異なる方法に取り組み、最終的には素敵なCSSグリッド駆動のテクニックに着陸します。彼はそれを簡単に実装したと呼びましたが、列の宣言を見ると、彼の5回目の繰り返しをしている人にとっては簡単に見えると思います。 🤣。そして、それは記事の半分に過ぎません。
最終的には、アフマドが複雑なレイアウトに取り組んでいると考えるのは、数行のCSSに煮詰められているだけでなく、かなり信じられないほどです。 CSSは確かにより強力です。しかし、それは簡単ですか? Geoff Grahamはええ、書くのは少し簡単です 実際、ある意味で。
いくつかの例を挙げると、グループ化スタイルが簡単になり、センターリングが簡単になり、翻訳のニーズが簡単になり、間隔が簡単になります。ジェフはもっと名前を付けます。そして、あらゆる点で、より簡単で本当に本当に簡単です。推論しやすく、それが言うことを行うことができる、ますます直接的なコード。
Roman Komarovは、Shrinkwrapの問題の概要を説明しています、それは多分少しニッチかもしれませんが、確かに非常に興味深いレイアウトの状況です。取引は、コンテンツの場合です ラップ、要素は基本的に利用可能なすべての幅を取り上げます。それほど奇妙ではありませんが、ラップされたタイトルがどのように見えるかを見ると text-wrap: balance;
、たとえば、少し奇妙に見えます。ヘッダーは取り上げるだけかもしれません 半分 視覚的にスペースがありますが、それでも利用可能なすべてのスペースを占有します。
ローマンはこれについて非常に深く、アンカーのような新しいテクノロジーを含むソリューションで、これだけで呼び出すのは非常に奇妙なものですが、ニーズはニーズです。これがそのようなニッチなことにはすべて多すぎると思うとき、ローマンは実際にかなり基本的で簡単なユースケースに到達します。全幅の泡が厄介に見えるチャットバブルのようなもの。またはヘッダーの両側にある装飾。
デビッド・ブッシェルには、楽しくて照らされた投稿があります ボタン固有のCSSスタイルについて。
ページが予期せずズームインするためだけにボタンを繰り返しタップしたことがありますか?たとえば、オーディオプレーヤーの巻き戻しとファーストフォワードボタン。この不要な副作用は、で削除できます
touch-action
。
そこには、すべてがまともなチャンスにあるカテゴリには、まともなカテゴリにある他の4人がいます。
Views: 0
こんにちは、Watanabe Jin (@Sicut_study)です。
突然ですが、皆さんはT3 Stackという言葉をご存知でしょうか?
T3 StackとはTheo氏によって2021年に提唱されたWebアプリケーション開発のための技術スタックです。
![]() |
作者をYoutubeで一度はみたことあるのではないいでしょうか? |
T3 Stackは以下のような思想があるアプリケーションです。
「簡素さ」「モジュール性」「フルスタック安全」を実現できる技術スタックを集めた総称をT3 Stackと呼びます。
で構成されています。
2021年に生まれて多く利用されてきたモダンなスキルスタックと呼ばれていた構成でしたが現代いくつかの不満が生まれてきました。
そんな不満をもったJosh氏が作ったのがJStackでした。
今回は「JStack」を使ってX(旧:Twitter)のクローンアプリを開発していきます。
JStackを使うことでエンドツーエンドの型安全を体験しながら、Clerkを組み合わせてよりT3 Stackに近い開発ができるようにチュートリアルを作成しました。
このチュートリアルをやることでモダンな技術スタックをまとめて手を動かして学ぶことできます!
こちらの教材にはより詳しく解説した動画もセットでご用意しています。
テキスト教材でわからない細かい箇所があれば動画も活用ください
Reactの基本的な仕組みを理解している人であれば2時間程度で学習することが可能です
JStackはJosh氏が開発したTypeScriptとNext.jsをベースにしたフルスタック開発ツールキットです。
JStackは、T3 Stackの制限を解消し、開発者体験とアプリケーションパフォーマンスを向上させるために作成されました。
スキルスタックは以下で構成されています。
これらの技術を組み合わせることで、JStackは開発者に高速で軽量な、かつエンドツーエンドで型安全な開発環境を提供します。
T3 Stackの作者であるTheo氏も絶賛しております。
今回はJStackの他にも以下の技術を利用することでモダンなアプリケーションを構築していきます。
認証で人気のサービスであるClerkやDBにはNeonを採用してTwitterクローンアプリを作ります。Clerk・Neonはともに無料枠があり簡単に利用できるため柔軟に開発できることで海外では人気となっています。
また、JStackの中ではReact Queryなどのライブラリも利用しています。
その他にも画像保存にはCloudinaryというサービスも利用しています。
JStackは簡単に環境構築ができます。Node.jsがあるかを確認します。
もしNode.jsがない場合はインストールをして下さい。
JStackをスタートガイドどおりにプロジェクト作成しましょう。
$ npx create-jstack-app@latest
┌ jStack CLI
│
◇ What will your project be called?
│ twitter-clone-app
│
◇ Which database ORM would you like to use?
│ Drizzle ORM
│
◇ Which Postgres provider would you like to use?
│ Neon
│
◇ Should we run 'npm install' for you?
│ Yes
このタイミングで「Drizzle ORM」と「Neon」を選択しました。
VSCodeでプロジェクトを開きます。
そしてターミナルでサーバーを起動するコマンドを実行しましょう。
http://localhost:3000にアクセスしましょう
この画面が表示されれば大丈夫です。
「Create Post」とありますが、ここはDBを接続しないとエラーになるのでこの後設定します。
次にShadcn/uiを導入します。
React19になってShadcn/uiのインストールが少し変わったので気をつけてください。
shdcnはTailwindCSSを利用していますが、JStackにはデフォルトで入っているのでshadcnからインストールを始めれば大丈夫です。
$ npx shadcn@latest init
[email protected]
Ok to proceed? (y) y
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS config. Found v4.
✔ Validating import alias.
✔ Which color would you like to use as the base color? › Neutral
✔ Writing components.json.
✔ Checking registry.
✔ Updating src/app/globals.css
Installing dependencies.
It looks like you are using React 19.
Some packages may fail to install due to peer dependency issues in npm (see https://ui.shadcn.com/react-19).
✔ How would you like to proceed? › Use --legacy-peer-deps
$ npx shadcn@latest add button
✔ How would you like to proceed? › Use --legacy-peer-deps
React19にインストールする場合はUse --legacy-peer-deps
を選択する必要があります。
「Create Post」のボタンをShadcnのボタンコンポーネントに変えてみましょう
src/app/components/post.tsx
"use client"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useState } from "react"
import { client } from "@/lib/client"
import { Button } from "@/components/ui/button" // 追加
export const RecentPost = () => {
const [name, setName] = useStatestring>("")
const queryClient = useQueryClient()
const { data: recentPost, isPending: isLoadingPosts } = useQuery({
queryKey: ["get-recent-post"],
queryFn: async () => {
const res = await client.post.recent.$get()
return await res.json()
},
})
const { mutate: createPost, isPending } = useMutation({
mutationFn: async ({ name }: { name: string }) => {
const res = await client.post.create.$post({ name })
return await res.json()
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["get-recent-post"] })
setName("")
},
})
return (
div className="w-full max-w-sm backdrop-blur-lg bg-black/15 px-8 py-6 rounded-md text-zinc-100/75 space-y-2">
{isLoadingPosts ? (
p className="text-[#ececf399] text-base/6">
Loading posts...
p>
) : recentPost ? (
p className="text-[#ececf399] text-base/6">
Your recent post: "{recentPost.name}"
p>
) : (
p className="text-[#ececf399] text-base/6">
You have no posts yet.
p>
)}
form
onSubmit={(e) => {
e.preventDefault()
createPost({ name })
}}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
createPost({ name })
}
}}
className="flex flex-col gap-4"
>
input
type="text"
placeholder="Enter a title..."
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full text-base/6 rounded-md bg-black/50 hover:bg-black/75 focus-visible:outline-none ring-2 ring-transparent hover:ring-zinc-800 focus:ring-zinc-800 focus:bg-black/75 transition h-12 px-4 py-2 text-zinc-100"
/>
{/* 修正 */}
Button
disabled={isPending}
type="submit"
variant="destructive"
>
{isPending ? "Creating..." : "Create Post"}
Button>
form>
div>
)
}
サーバーを再起動してアクセスをすると、ボタンが正しく表示されているので導入がうまくいっていることが確認できました。
ここからは「Neon」と「Drizzle」を使ってDBの初期設定をしていきます。
いまアプリにあるポストの作成と最新ポストの表示を目指します。
まずはNeonでプロジェクトを作成します。アカウントがない方は作成してログインしてください。
「New Project」をクリック
これらを入力して「Create」をクリック
この時点ではテーブルやスキーマなどはないので、コードで書いて反映をさせます。
ここで利用できるのがDrizzle ORMです。
このプロジェクトではsrc/server/db/schema.ts
にテーブル定義のサンプルが書いてあります。
これをDBに反映させましょう。
まずは.env
にDB_URLを追加します。
Neonを開いて「Connect」を押して「Connect String」をコピーして貼り付けます。
.env
DATABASE_URL=あなたのURL
スキーマとテーブルをDBに反映させるコマンドは事前に用意されています。
package.json
{
"name": "twitter-clone-app",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev",
"lint": "next lint",
"start": "next start"
},
npm run db:generate
とすることでテーブルの型情報が開発で使えるようになります。npm run db:push
とすることでNeonのDBを更新することができます。
$ npm run db:push // Neonを更新
[✓] Pulling schema from database...
[✓] Changes applied
実際にNeonを開いて、左メニューから「Tables」を選ぶとテーブルが自動で作成されたことがわかります。
これでDBの連携はできたので実際にアプリケーションのフォームに入力して「Create Post」をクリックします。
入力したものが表示されています。DBをみてもデータが追加されていることがわかります。
続いてClerkを使ってログインできるようにします。
アカウントがない人は作成をしてから次に進んでください。
「Create appplication」をクリック (横にあるプロジェクトは無視してください)
「Application name」にtwitter-clone-appと入力して「Create application」をクリック
ここからは初期設定を手順通りに行います。
$ npm install @clerk/nextjs
手順2のAPIキーをすべてコピーして.env
に貼り付けます
.env
DATABASE_URL=あなたのDATABASE_URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=あなたのNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=あなたのCLERK_SECRET_KEY
次にmiddleware.tsを作成します。
$ touch src/middleware.ts
middleware.ts
import { clerkMiddleware } from "@clerk/nextjs/server";
export default clerkMiddleware();
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
middleware.tsはリクエストがルートハンドラーやページによって処理される前に実行されるコードで、リクエストの検査や変更、レスポンスの変更などを行うことができます。今回はそれぞれのページにアクセスするごとに認証が必要かを判定する役割で利用します。
次に今回作成するアプリでClerkを利用できるようにProviderを設定します。
src/app/layout.ts
import type { Metadata } from "next";
import { ClerkProvider, SignedIn, SignedOut } from "@clerk/nextjs";
import "./globals.css";
export const metadata: Metadata = {
title: "JStack Twitter Clone",
description: "Twitter clone created using JStack",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
export default function RootLayout({
children,
}: Readonly{
children: React.ReactNode;
}>) {
return (
ClerkProvider>
html lang="en">
body className="antialiased">
div className="flex min-h-screen">
main className="flex-1 transition-all duration-300">
Providers>{children}/Providers>
/main>
/div>
/body>
/html>
/ClerkProvider>
);
}
全ページ共通のレイアウトでClerkProviderを使ってchildren(実際のページ固有の内容)を囲うことで、認証を全てのページで利用することができます。
Providerを使うことで簡単に認証しているかどうかや、認証しているユーザーの情報を取得できるようになります。
ClerkProvider>
(省略
/ClerkProvider>
次にログインが必要な画面を用意します。今回は/
をTwitterのタイムラインとするので認証していないとみれない(認証してない場合はログイン画面にリダイレクト)ように設定します。
middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; // 追加
const isProtectedRoute = createRouteMatcher(["/"]); // 追加
// 追加
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect();
}
});
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
};
createRouteMatcher
で認証したいページを設定します。
const isProtectedRoute = createRouteMatcher(["/"]);
/
に対応するページを作成します。
src/app/page.tsx
import React from "react";
function Home() {
return div>Homediv>;
}
export default Home;
clerkMiddleware
の中ではもしパスがマッチするなら認証を確かめる設定をしています
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
await auth.protect();
}
});
auth.protect()をすることで認証済み以外のユーザーはアクセスできなくなります。
実際に確かめてみます。
http://localhost:3000にアクセスすると認証画面にリダイレクトされます。
実際にアカウントを作成します。今回はGoogleでログインしてみます。
「Sign up」から「Continue With Google」をクリック(リダイレクト先はSign inなので注意)
「Verfify You are Human」チェック入れてアカウントを選択して「次へ」をクリック
ログインができると/
にリダイレクトされてHomeがみれました
このままだとログインしたままになって検証がしづらいのでログアウトボタンをつけてみます。
src/app/page.tsx
import { SignOutButton } from "@clerk/nextjs";
import React from "react";
function Home() {
return (
div>
h1>Homeh1>
SignOutButton />
div>
);
}
export default Home;
「Sigin Out」をクリックするとログアウトできます。
Clerkには便利なコンポーネントがあり、
はログアウトを実装できるものです。
ここからはClerkのWebHookという機能を使ってClerkで認証をしたらDBにもユーザーデータを保存する仕組みを実装します。
まずは今回実装するWebHookの仕組みから解説していきます。
ClerkにはWebHookという仕組みがあり、「Sign UP」(ユーザー作成)が行われたら、/api/webhook/clerk
を叩くように設定を行うことができます。
/api/clerk/webhook
には作成されたユーザーの情報を渡します。
そして/api/clerk/webhook
のAPIの中でDBにユーザー情報を保存する処理が動いて保存が完了します。
実際にこの流れで設定を行っていきます。まずはClerkを開いてください
「Configure」から「Webhooks」を開いて「Add Endpoint」をクリック
Endpoint URLには実際にClerkが叩くエンドポイント
Subscribe to eventにはどの操作が行われたときにエンドポイントを叩くかを設定します。
Endpoint URL : http://localhost:3000/api/webhook/clerk
Subscribe to event : user.created
「Create」を押すとWebHookが作成できるはずですがエラーになります。
localhost:3000
としてしまうとClerk側のホストのlocalhost:3000
という意味になってしまい私たちのアプリのAPIを叩けません。つまりアプリをデプロイしてあげる必要があります。
今回はデプロイすると開発が止まってしまうためngrok
を使ってポートフォワードをします。ngrokの解説をしていきます。
ポートフォワードという仕組みをngrokでおこなうことで、ngrokのエンドポイントを叩くと私たちのローカルのエンドポイントにプロキシ(中継)をしてくれてlocalhostのAPIを叩けるようになります。
それではngrok
をインストールします。それぞれの環境にあった方法でインストールしてください。
初回にはトークン認証も必要です。難しくないので調べて進めましょう
それでは実際にポートフォワードをしてみましょう
$ ngrok http 3000
Session Status online
Account Watanabe Jin (Plan: Free)
Update update available (version 3.21.0, Ctrl-U to update)
Version 3.20.0
Region Japan (jp)
Web Interface http://127.0.0.1:4040
Forwarding https://ad2e-113-43-203-90.ngrok-free.app -> http://localhost:3000
https://ad2e-113-43-203-90.ngrok-free.app
にリクエストするとhttp://localhost:3000
にポートフォワードされるようになりました。(人それぞれURLは違います)
実際にこのURLでアクセスしてみるとログイン画面が表示されます。
※ ただし実際にログインはClerkの関係で使えないので注意
それではこのURLをWebHookとして設定しましょう
EndPoint URL : [あなたのURL]/api/webhook/clerk
「Create」をおして設定は完了です。ngrokを切るとURLが変わるので切らないようにしてください。
ここからはJStackの機能の一つである型セーフなAPI開発を行っていきます。
JStackではsrc/server
にAPIに関するファイルを作成します。
まずは試しに簡単なAPIを作ってみましょう
$ touch src/server/routers/ping-router.ts
ping-router.ts
import { j, publicProcedure } from "../jstack";
export const pingRouter = j.router({
ping: publicProcedure.get(({ c }) => {
return c.json({ message: "Pong!" });
}),
});
JStackではj.router
にルーティングの設定をすることで型安全などの恩恵を受けることができます。
ping: publicProcedure.get(({ c }) => {
return c.json({ message: "Pong!" });
}),
/ping
のエンドポイントを作る例です。publicProcedure
を使うことでミドルウェアのようなものをJStackでも簡単に実装することも可能なようです。
APIを実装したのでこれを使えるように設定してきます。
src/server/index.ts
import { j } from "./jstack";
import { pingRouter } from "./routers/ping-router";
import { postRouter } from "./routers/post-router";
/**
* This is your base API.
* Here, you can handle errors, not-found responses, cors and more.
*
* @see https://jstack.app/docs/backend/app-router
*/
const api = j
.router()
.basePath("/api")
.use(j.defaults.cors)
.onError(j.defaults.errorHandler);
/**
* This is the main router for your server.
* All routers in /server/routers should be added here manually.
*/
const appRouter = j.mergeRouters(api, {
post: postRouter,
system: pingRouter,
});
export type AppRouter = typeof appRouter;
export default appRouter;
j.mergeRouters
にapi
の基本設定とルーティングを渡します。
const api = j
.router()
.basePath("/api")
.use(j.defaults.cors)
.onError(j.defaults.errorHandler);
APIの基本設定はCORSや/api
からエンドポイントが始まることが設定されています。
const appRouter = j.mergeRouters(api, {
post: postRouter,
systems: pingRouter,
});
実際のルーティングにはすでに最初から実装されている/post
と今回追加したpingRouter
を設定します。
system:とすることで/system
となります。pingRouterでは
ping: publicProcedure.get(({ c }) => {
としているのでAPIは/api/systems/ping
とすると叩けるはずです。
$ curl localhost:3000/api/systems/ping
{"message":"Pong!"}
ngrokでポートフォワードしたURLでも叩けるかを確認しましょう
$ curl あなたのngrokのURL/api/systems/ping
{"message":"Pong!"}
ここでAPIにも将来的に認証していないとフロントエンドから叩けないようにしたいので設定をClerkにいれます。
src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher(["/"]);
const isWebhookRoute = createRouteMatcher(["/api/webhook/clerk(.*)"]);
const isPingRoute = createRouteMatcher(["/api/systems/ping(.*)"]);
export default clerkMiddleware(async (auth, req) => {
if (isWebhookRoute(req) || isPingRoute(req)) {
return;
}
if (isProtectedRoute(req)) {
await auth.protect();
}
});
export const config = {
matcher: [
// Skip Next.js internals and all static files, unless found in search params
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
// Always run for API routes
"/(api|trpc)(.*)",
],
};
今回はホワイトリスト形式で叩けるものはreturn
することで認証の対象から外しました。
それでは/api/webhook/clerk
のAPIを作成しましょう。
実装の内容は公式ドキュメントを参考にしていきます。
まずはWebHookが正しいリクエストなのか?(悪意を持ったAPIリクエストでないか)を検証するのに使えるライブラリを入れます。
次にDBにテーブルを用意したいのでschema.ts
を更新してDBに反映させます。
src/server/db/schema.ts
import { pgTable, serial, text, timestamp, index } from "drizzle-orm/pg-core";
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
name: text("name").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().notNull(),
},
(table) => [index("Post_name_idx").on(table.name)]
);
export const users = pgTable(
"users",
{
id: serial("id").primaryKey(),
clerkId: text("clerkId").notNull(),
email: text("email").notNull(),
name: text("name").notNull(),
handle: text("handle").notNull(),
avatarUrl: text("avatarUrl"),
bio: text("bio"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().notNull(),
},
(table) => [index("User_email_idx").on(table.email)]
);
ユーザーテーブルは以下の要素を持っています。
column | 内容 |
---|---|
id | 識別子 |
clerkId | clerkが持つid |
メールアドレス | |
name | 名前 |
handle | ハンドルネーム(@から始まるやつ) |
avatarUrl | アバター画像のURL |
bio | 自己紹介文章 |
createdAt | 作成日 |
updatedAt | 更新日 |
それではテーブルの型情報の出力とNeonへの更新をしましょう
$ npm run db:generate
$ npm run db:push
Neonをみてテーブルができていれば成功です。ClerkでSign upしたらここにユーザーレコードが作成されることを目指してAPIを開発しましょう
次にAPIを実装するためのファイルをつくります。
$ touch src/server/routers/clerk-webhook-router.ts
clerk-webhook-router.ts
import { j, publicProcedure } from "../jstack";
import { WebhookEvent } from "@clerk/nextjs/server";
import { Webhook } from "svix";
import { users } from "@/server/db/schema";
export const clerkWebhookRouter = j.router({
clerk: publicProcedure.post(async ({ c, ctx }) => {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
const { db } = ctx;
if (!WEBHOOK_SECRET) {
throw new Error("CLERK_WEBHOOK_SECRET is not set");
}
const payload = await c.req.text();
const headers = c.req.raw.headers;
const svixHeaders = {
"svix-id": headers.get("svix-id") || "",
"svix-timestamp": headers.get("svix-timestamp") || "",
"svix-signature": headers.get("svix-signature") || "",
};
const wh = new Webhook(WEBHOOK_SECRET);
let evt: WebhookEvent;
try {
evt = wh.verify(payload, svixHeaders) as WebhookEvent;
} catch (err) {
console.error("Webhook verification failed", err);
return c.json({ error: "Webhook verification failed" }, 400);
}
const eventType = evt.type;
if (eventType === "user.created") {
const { id, email_addresses, first_name, last_name } = evt.data;
console.log(`User ${id} was created`);
await db.insert(users).values({
clerkId: id,
email: email_addresses[0]?.email_address ?? "",
name: `${first_name || ""} ${last_name || ""}`.trim(),
avatarUrl: "",
bio: "",
handle: email_addresses[0]?.email_address.split("@")[0] ?? "",
});
}
return c.json({ message: "Webhook received successfully" }, 200);
}),
});
ここはドキュメント通りではありますがざっくりと解説はしていきます。
まずはコンテキストからdbを操作できるdb
を受け取ります。これはJStackを使っていればctx
から受け取れるものです。dbを使うことで簡単にdrizzle
を利用できます。
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
const { db } = ctx;
if (!WEBHOOK_SECRET) {
throw new Error("CLERK_WEBHOOK_SECRET is not set");
}
WEBHOOK_SERCRETはClerkのWebHooksから取得できるので.envに設定しましょう
「Signing Secert」からコピーして.envに追加
.env
DATABASE_URL=あなたのDATABASE_URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=あなたのNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=あなたのCLERK_SECRET_KEY
CLERK_WEBHOOK_SECRET=あなたのCLERK_WEBHOOK_SECRET
次にWebHooksが正しい情報かを検証するところです。
const payload = await c.req.text();
const headers = c.req.raw.headers;
const svixHeaders = {
"svix-id": headers.get("svix-id") || "",
"svix-timestamp": headers.get("svix-timestamp") || "",
"svix-signature": headers.get("svix-signature") || "",
};
const wh = new Webhook(WEBHOOK_SECRET);
let evt: WebhookEvent;
try {
evt = wh.verify(payload, svixHeaders) as WebhookEvent;
} catch (err) {
console.error("Webhook verification failed", err);
return c.json({ error: "Webhook verification failed" }, 400);
}
ヘッダーとペイロード(送られてきた情報)を使ってWebHooksに検証のリクエストをしています
evt = wh.verify(payload, svixHeaders) as WebhookEvent;
問題が起きるとエラーとなり処理は終了します。
正しいリクエストであれば送られてくるイベントタイプをチェックします。
const eventType = evt.type;
if (eventType === "user.created") {
const { id, email_addresses, first_name, last_name } = evt.data;
console.log(`User ${id} was created`);
await db.insert(users).values({
clerkId: id,
email: email_addresses[0]?.email_address ?? "",
name: `${first_name || ""} ${last_name || ""}`.trim(),
avatarUrl: "",
bio: "",
handle: email_addresses[0]?.email_address?.split("@")[0] ?? "",,
});
}
今回はuser.created
をWebHooksで選択したので、eventTypeに送られてきます。
もしuser.created
ならDBにインサートする処理を行います。
ハンドルはメールアドレスの@以前を初期値に設定しています。
それではAPIを設定します。
src/server/index.ts
import { j } from "./jstack";
import { clerkWebhookRouter } from "./routers/clerk-webhook-router";
import { pingRouter } from "./routers/ping-router";
import { postRouter } from "./routers/post-router";
/**
* This is your base API.
* Here, you can handle errors, not-found responses, cors and more.
*
* @see https://jstack.app/docs/backend/app-router
*/
const api = j
.router()
.basePath("/api")
.use(j.defaults.cors)
.onError(j.defaults.errorHandler);
/**
* This is the main router for your server.
* All routers in /server/routers should be added here manually.
*/
const appRouter = j.mergeRouters(api, {
post: postRouter,
systems: pingRouter,
webhook: clerkWebhookRouter, // 追加
});
export type AppRouter = typeof appRouter;
export default appRouter;
それでは実際にWebHookが正しく機能するか(APIと疎通できるか)をチェックします。
Clerkのダッシュボードを開きます。
「Testing」を選択してSend Eventをuser.created
にします。
「Send Example」を送信してSuccessed
になれば問題ないです。エラーが出る場合はコンソールのログをみて対応してください。
それでは実際にClerkを使ってログインをしてDBにユーザー情報が保存できるかを確かめます。
先程Googleログインしたアカウントは削除します。Clerkの「User」を開いてください
三点リーダーから「Delete」を押して「Delte」を選択すると削除できます。
同じユーザーはsing upができないので削除を行っています。
それでは実際にhttp://localhost:3000からログインをしてみます。
Neonをみるとユーザーテーブルにデータが追加されていることがわかります。
次にタイムライン画面を実装していきます。
この画面はコンポーネントとして「サイドメニュー」「ポスト投稿フォーム」「投稿を表示するタイムライン」の3つにコンポーネントをわけて実装します。
サイドメニューはすべてで共通なのでレイアウトに追加します。
$ touch src/components/PostForm.tsx
$ touch src/components/PostList.tsx
$ touch src/components/SideMenu.tsx
PostForm.tsx
import React from "react";
function PostForm() {
return div>PostFormdiv>;
}
export default PostForm;
PorstList.tsx
import React from "react";
function PostList() {
return div>PostListdiv>;
}
export default PostList;
src/app/components/SideMenu.tsx
import { SignOutButton } from "@clerk/nextjs";
import React from "react";
function SideMenu() {
return (
div>
SignOutButton />
div>
);
}
export default SideMenu;
src/app/page.tsx
import PostForm from "@/components/PostForm";
import PostList from "@/components/PostList";
import React from "react";
function Home() {
return (
div className="max-w-xl mx-auto min-h-screen">
div>
PostForm />
div>
PostList />
div>
);
}
export default Home;
src/app/layout.tsx
import type { Metadata } from "next";
import { ClerkProvider, SignedIn, SignedOut } from "@clerk/nextjs";
import "./globals.css";
import SideMenu from "@/components/SideMenu";
export const metadata: Metadata = {
title: "JStack Twitter Clone",
description: "Twitter clone created using JStack",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
export default function RootLayout({
children,
}: Readonly{
children: React.ReactNode;
}>) {
return (
ClerkProvider>
html lang="en">
body className="antialiased">
div className="flex min-h-screen">
SideMenu />
main className="flex-1 transition-all duration-300">
{children}
main>
div>
body>
html>
ClerkProvider>
);
}
レイアウトだけなのでまずは投稿を表示する機能だけ作成します。
posts
テーブルを作成しましょう。実はもうすでにデフォルトで用意されているので修正をしてDBに反映していきます。
src/server/db/schema.ts
import {
pgTable,
serial,
text,
timestamp,
index,
integer,
} from "drizzle-orm/pg-core";
export const users = pgTable(
"users",
{
id: serial("id").primaryKey(),
clerkId: text("clerkId").notNull(),
email: text("email").notNull(),
name: text("name").notNull(),
handle: text("handle").notNull(),
avatarUrl: text("avatarUrl"),
bio: text("bio"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().notNull(),
},
(table) => [index("User_email_idx").on(table.email)]
);
export const posts = pgTable(
"posts",
{
id: serial("id").primaryKey(),
content: text("content").notNull(),
handle: text("handle").notNull(),
like: integer("like").notNull().default(0),
image: text("image"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().notNull(),
},
(table) => [index("Post_userId_idx").on(table.handle)]
);
あとでプロフィール画面でユーザーの投稿だけを表示しやすくするためにユーザーとポストはハンドルネームを使って紐付けるようにしました
(table) => [index("Post_userId_idx").on(table.handle)]
ではテーブルの型情報を出力します。
$ npm run db:generate
~ name › content column will be renamed
+ handle column will be created
+ like column will be created
+ image column will be created
--- all columns conflicts in posts table resolved ---
以前との変更箇所を聞かれるので合わせていきます。
次にDBに変更を反映します。
$ npm run db:push
~ name › content column will be renamed
+ handle column will be created
+ like column will be created
+ image column will be created
--- all columns conflicts in posts table resolved ---
Warning Found data-loss statements:
· You're about to add not-null handle column without default value, which contains 3 items
THIS ACTION WILL CAUSE DATA LOSS AND CANNOT BE REVERTED
Do you still want to push changes?
[✓] Changes applied
ここも同じく変更を一つずつ聞かれます。そのあとにレコードあるけど消していい?と聞かれるので消しましょう。
テーブルのスキーマが違うレコードがあるとおかしくなるため削除しています。
最初に作成したレコードは全て消えていて、カラムも新しくなっていることがわかります。
次にテストデータをいくつか追加しましょう。
「Add record」をクリックすると追加できるので以下のデータを作成してください
データ1
項目 | 値 |
---|---|
id | DEFAULT |
content | Hello World |
createdAt | DEFAULT |
updatedAt | DEFAULT |
hanlde | hoge |
like | 10 |
image | NULL |
入力して「Save 1 Chnage」を押すと保存されます。
データ2
項目 | 値 |
---|---|
id | DEFAULT |
content | Hello React |
createdAt | DEFAULT |
updatedAt | DEFAULT |
hanlde | react |
like | 100 |
image | NULL |
データ3
項目 | 値 |
---|---|
id | DEFAULT |
content | こんにちは |
createdAt | DEFAULT |
updatedAt | DEFAULT |
hanlde | japan |
like | 200 |
image | NULL |
3つのレコードが作成されればOKです。
それでは実際にこのレコードをJStackを使って表示しましょう
まずはAPIから作成していきます。
src/server/routers/post-router.ts
import { posts, users } from "@/server/db/schema";
import { desc, eq } from "drizzle-orm";
import { j, publicProcedure } from "../jstack";
export const postRouter = j.router({
all: publicProcedure.query(async ({ c, ctx }) => {
const { db } = ctx;
const postsData = await db
.select({
id: posts.id,
content: posts.content,
handle: users.handle,
name: users.name,
like: posts.like,
image: posts.image,
createdAt: posts.createdAt,
avatarUrl: users.avatarUrl,
})
.from(posts)
.innerJoin(users, eq(users.handle, posts.handle))
.orderBy(desc(posts.createdAt));
return c.superjson(postsData);
}),
});
clerkIdやemailは不要なので除いて必要な要素だけを取得しています。
userテーブルのhandleとnameの情報がほしいのでhanlde
キーでusersテーブルとpostsテーブルを結合しています。
drizzleを使って作成日順の降順でソートしてレスポンスを返します。
const postsData = await db
.select({
id: posts.id,
content: posts.content,
handle: users.handle,
name: users.name,
like: posts.like,
image: posts.image,
createdAt: posts.createdAt,
avatarUrl: users.avatarUrl,
})
.from(posts)
.innerJoin(users, eq(users.handle, posts.handle))
.orderBy(desc(posts.createdAt));
return c.superjson(postsData);
ここでJStackを利用することでテーブルのスキーマからusersやpostsの補完が効いていることもわかります。(これはgenerateでテーブルの情報を型に出力して利用しているからです)
それでは実際に叩いてみましょう
$ curl localhost:3000/api/post/all
{"json":[]}
なぜかデータが帰ってきません。これはユーザーとポストをhandle
で結びつけられていないからです。
とりあえずpostsのhanldeをすべてユーザーのhandleに更新しましょう(人それぞれ異なります)
handleはログインしているメールアドレスの@以前が設定されています
私の場合はhandle
がjin.watanabe.6g
なのでこの値をpostsの3つのデータのhandleに入れて更新します。
もう一度APIを叩くと正しくデータが返ってきます。
$ curl localhost:3000/api/post/all
{"json":[{"id":6,"content":"こんにちは","handle":"jin.watanabe.6g","name":"臣 渡邉","like":200,"image":null,"createdAt":"2025-03-15T06:26:48.272Z","avatarUrl":""},{"id":5,"content":"Hello React","handle":"jin.watanabe.6g","name":"臣 渡邉","like":100,"image":"","avatarUrl":""},{"id":4,"content":"Hello World","handle":"jin.watanabe.6g","name":"臣 渡邉","like":10,"image":null,"createdAt":"2025-03-15T06:25:03.720Z","avatarUrl":""}],"meta":{"values":{"0.createdAt":["Date"],"1.createdAt":["Date"],"2.createdAt":["Date"]}}}
次にこのAPIを最初に叩いてタイムライン表示をします。
まずはドメインを作成します。
$ mkdir src/domain
$ touch src/domain/Post.ts
Post.ts
export type Post = {
id: number;
content: string;
like: number;
handle: string;
image: string | null;
createdAt: Date | string;
avatarUrl: string | null;
name: string;
};
私たちがこのアプリで扱う投稿をデータ型で表現しました。
PostList.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { Post } from "@/domain/Post";
export default function PostList() {
const { data: posts, isLoading } = useQueryPost[]>({
queryKey: ["posts"],
queryFn: async () => {
const res = await client.post.all.$get();
const data = await res.json();
return data as Post[];
},
});
if (isLoading || !posts) {
return p>Loading...p>;
}
return (
div className="divide-y divide-gray-100">
{posts.map((post: Post) => (
div key={post.id}>
p>{post.content}p>
p>{post.like}p>
p>{post.image}p>
p>{post.name}p>
p>{post.handle}p>
div>
))}
div>
);
}
データ取得にはReact Query
を利用するためクライアントサイドであることを明示的に指定します。
useQuery
を使ってデータ取得をします。
const queryClient = useQueryClient();
const { data: posts, isLoading } = useQueryPost[]>({
queryKey: ["posts"],
queryFn: async () => {
const res = await client.post.all.$get();
const data = await res.json();
return data as Post[];
},
});
queryKey
は「posts」というキーでクエリ結果がキャッシュされることを表現しています。
同じコンポーネントや他のコンポーネントで同じキーを使用すると、重複したAPIリクエストが発生せず、キャッシュされたデータが使用されます
queryFn
で実際の取得処理を実装しています。
const res = await client.post.all.$get();
JStackが提供するclient
を利用することでAPIの返却の型情報を利用することができます。data
にホバーをすると正しく型情報が表示されます。
これでフロントエンドからバックエンドまで型安全に開発が可能です。
dataはPost[]になっているのですが最後にas Post[]
をつけないとuseQueryで怒られるのでいれておきます。
ローディング状態またはpostsがないときはローディング表示を出しておきます。
if (isLoading || !posts) {
return p>Loading...p>;
}
それでは実際に画面を確認しましょう
QuryClientProviderを設定しろと怒られているので設定します。
$ touch src/components/Provider.tsx
src/components/Provider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PropsWithChildren } from "react";
const queryClient = new QueryClient();
export const QueryProviders = ({ children }: PropsWithChildren) => {
return (
QueryClientProvider client={queryClient}>{children}QueryClientProvider>
);
};
layoutに設定してどのページでもReactQueryが使えるようにしましょう
src/app/layout.tsx
import type { Metadata } from "next";
import { ClerkProvider, SignedIn, SignedOut } from "@clerk/nextjs";
import "./globals.css";
import SideMenu from "@/components/SideMenu";
import { Providers } from "./components/providers";
export const metadata: Metadata = {
title: "JStack Twitter Clone",
description: "Twitter clone created using JStack",
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
export default function RootLayout({
children,
}: Readonly{
children: React.ReactNode;
}>) {
return (
ClerkProvider>
html lang="en">
body className="antialiased">
div className="flex min-h-screen">
SideMenu />
main className="flex-1 transition-all duration-300">
{/* 追加 */}
Providers>{children}Providers>
main>
div>
body>
html>
ClerkProvider>
);
}
投稿のデータが表示されるようになりました。
JStackを使うことで簡単にAPIとの連携ができることがわかります。
次にポストを作成できる画面を実装します。
ここではポストの投稿のあとにCloudinaryを利用した画像保存についても学びます。
まずはCloudinaryのアカウントを作成してください。
「Go to API Keys」(またはView API Keys)をクリック
「Generate New API Key」をクリック
作成できたら「Cloud name」と「API Key」と「API Secret」の値をコピーして.envに貼り付けます
.env
DATABASE_URL=あなたのDATABASE_URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=あなたのNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
CLERK_SECRET_KEY=あなたのCLERK_SECRET_KEY
CLERK_WEBHOOK_SECRET=あなたのCLERK_WEBHOOK_SECRET
CLOUDINARY_CLOUD_NAME=あなたのCLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY=あなたのCLOUDINARY_API_KEY
CLOUDINARY_API_SECRET=あなたのCLOUDINARY_API_SECRET
Cloudinaryに画像をアップロードする関数を作成します。
$ npm i cloudinary
$ touch src/lib/cloudinary.ts
src/lib/cloudenary.ts
import { v2 as cloudinary } from "cloudinary";
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME || "",
api_key: process.env.CLOUDINARY_API_KEY || "",
api_secret: process.env.CLOUDINARY_API_SECRET || "",
});
export async function uploadImage(file: string): Promisestring> {
try {
const result = await cloudinary.uploader.upload(file, {
folder: "twitter-clone",
});
return result.secure_url;
} catch (error) {
console.error("Cloudinaryへのアップロードエラー:", error);
throw new Error("画像のアップロードに失敗しました");
}
}
シンプルな関数です。今回はtwitter-clone
というディレクトリに画像を保存しています。
const result = await cloudinary.uploader.upload(file, {
folder: "twitter-clone",
});
APIを作成します。
src/server/routers/post-router.ts
import { posts, users } from "@/server/db/schema";
import { desc, eq } from "drizzle-orm";
import { j, publicProcedure } from "../jstack";
import { z } from "zod";
import { uploadImage } from "@/lib/cordinary";
export const postRouter = j.router({
all: publicProcedure.query(async ({ c, ctx }) => {
const { db } = ctx;
const postsData = await db
.select({
id: posts.id,
content: posts.content,
handle: users.handle,
name: users.name,
like: posts.like,
image: posts.image,
createdAt: posts.createdAt,
avatarUrl: users.avatarUrl,
})
.from(posts)
.innerJoin(users, eq(users.handle, posts.handle))
.orderBy(desc(posts.createdAt));
return c.superjson(postsData);
}),
// 追加
create: publicProcedure
.input(
z.object({
content: z.string().min(1),
handle: z.string(),
image: z.string().optional(),
})
)
.mutation(async ({ ctx, c, input }) => {
const { content, handle, image } = input;
const { db } = ctx;
let imageUrl = null;
if (image) {
try {
imageUrl = await uploadImage(image);
} catch (error) {
console.error("Error uploading image:", error);
}
}
const post = await db.insert(posts).values({
content,
handle,
image: imageUrl,
});
return c.superjson(post);
}),
});
/api/posts/create
を追加しました。
まずはZodを使って入力値の検証をしています。もしcontentやhandleが送られていないとエラーになります。
.input(
z.object({
content: z.string().min(1),
handle: z.string(),
image: z.string().optional(),
})
)
次に実際の処理になりますがmutation
を使っています。
.mutation(async ({ ctx, c, input }) => {
GETリクエストのときはquery
、POSTのときはmutation
を使います。
あとは画像があればCloudinaryにアップロードしてDBのインサートをするだけです。
if (image) {
try {
imageUrl = await uploadImage(image);
} catch (error) {
console.error("Error uploading image:", error);
}
}
const post = await db.insert(posts).values({
content,
handle,
image: imageUrl,
});
投稿フォームのコンポーネントを作成しましょう
まずはログイン情報を表すドメインを用意します。
$ touch src/domain/User.ts
src/domain/User.ts
export type UserProfile = {
id: number;
clerkId: string;
email: string;
name: string;
handle: string;
avatarUrl: string | null;
bio: string | null;
createdAt: string;
updatedAt: string;
};
次にフォームを作ります。
src/components/PostForm.tsx
"use client";
import { useState, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import Image from "next/image";
import { UserProfile } from "@/domain/User";
export default function PostForm({ user }: { user: UserProfile }) {
const [content, setContent] = useState("");
const [image, setImage] = useStatestring | null>(null);
const fileInputRef = useRefHTMLInputElement>(null);
const queryClient = useQueryClient();
const createPostMutation = useMutation({
mutationFn: async (newPost: {
content: string;
handle: string;
image?: string;
}) => {
const res = await client.post.create.$post(newPost);
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
setContent("");
setImage(null);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim()) {
createPostMutation.mutate({
content,
handle: user.handle,
image: image || undefined,
});
}
};
const handleImageSelect = (e: React.ChangeEventHTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setImage(base64String);
};
reader.readAsDataURL(file);
}
};
const handleImageButtonClick = () => {
fileInputRef.current?.click();
};
return (
form onSubmit={handleSubmit} className="p-4">
div className="flex gap-3">
div className="flex-shrink-0">
{user.avatarUrl ? (
Image
src={user.avatarUrl}
alt={user.name}
width={40}
height={40}
className="rounded-full"
/>
) : (
div className="w-10 h-10 rounded-full bg-gray-200">div>
)}
div>
div className="flex-grow">
textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
/>
{image && (
Image src={image} alt="Upload preview" width={500} height={300} />
)}
div>
button type="button" onClick={handleImageButtonClick}>
画像
button>
input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageSelect}
/>
div>
button type="submit">投稿するbutton>
div>
div>
form>
);
}
このコンポーネントでは親コンポーネントからログインユーザーの情報を受け取ります。
export default function PostForm({ user }: { user: UserProfile }) {
投稿のデータにハンドルネームを含めるためです。
const queryClient = useQueryClient();
const createPostMutation = useMutation({
mutationFn: async (newPost: {
content: string;
handle: string;
image?: string;
}) => {
const res = await client.post.create.$post(newPost);
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
setContent("");
setImage(null);
},
});
今回もReactQueryを使いますが、今回はデータ作成のときに利用するのでuseMutation
を使います。
mutationFnに作成の処理を書きます。
mutationFn: async (newPost: {
content: string;
handle: string;
image?: string;
}) => {
const res = await client.post.create.$post(newPost);
return await res.json();
},
React QueryはmtationFnが成功したときの処理を書くことができます。
ここでは新規作成できたらデータを再取得するようにしています。こうすることでタイムラインが更新されます。
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
setContent("");
setImage(null);
},
});
ここでのポイントはqueryKey
にposts
を選択していることです。
PostListコンポーネントでも同じキーを使っているので、PostListコンポーネントでも再fetchが走って更新されるのです。
フォームで入力されたContentとImageはステートで管理しているので投稿できたら空にしてあげます。
div className="flex-grow">
textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
/>
投稿のContentのフォームは入力される度にContentのステートに入力した値を保存しています。
div>
button type="button" onClick={handleImageButtonClick}>
画像
button>
input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageSelect}
/>
div>
画像の投稿は画像ボタンを押すとhandleImageButtonClick
が実行されます。
const fileInputRef = useRefHTMLInputElement>(null);
(省略)
const handleImageButtonClick = () => {
fileInputRef.current?.click();
};
(省略)
input
ref={fileInputRef}
クリックするとfileInputRef
をクリックします。fileInputRefはrefでインプットフォームと結びついているのでファイル選択のモーダルを開くことができます。ファイルを選択するとhandleImageSelect
が実行されます。
const handleImageSelect = (e: React.ChangeEventHTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setImage(base64String);
};
reader.readAsDataURL(file);
}
};
画像データがあればbase64にエンコードしてImageのステートに保持しておきます。
保存ボタンをおすとsubmit
イベントが発火してformのonSubmit
に設定した関数が実行されます。
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim()) {
createPostMutation.mutate({
content,
handle: user.handle,
image: image || undefined,
});
}
};
投稿内容とユーザーのハンドルネームと画像をuseMutation
に渡してAPIを叩いています。
次にユーザー情報をPostFormに渡したいのですが、Clerkの情報にはhandleがなくログイン認証したデータのclerkIdからusersのDBを検索してハンドルネームを取得する必要があります。
clerkIdからユーザーの情報を取得するAPIを作ります。
$ touch src/server/routers/profile-router.ts
src/server/routers/profile-router.ts
import { z } from "zod";
import { users } from "../db/schema";
import { j, publicProcedure } from "../jstack";
import { eq } from "drizzle-orm";
export const profileRouter = j.router({
get: publicProcedure
.input(
z.object({
userId: z.string(),
})
)
.query(async ({ ctx, c, input }) => {
const { db } = ctx;
const { userId } = input;
const user = await db
.select()
.from(users)
.where(eq(users.clerkId, userId))
.limit(1);
return c.json(user[0] ?? null);
}),
});
userIdがないと検索できないのでzodでバリデーションをかけます
get: publicProcedure
.input(
z.object({
userId: z.string(),
})
)
userIdをインプットから取得して検索をします。取得した1件を返しています。
const { userId } = input;
const user = await db
.select()
.from(users)
.where(eq(users.clerkId, userId))
.limit(1);
return c.json(user[0] ?? null);
新しいrouterを作ったので登録をします。
src/server/index.ts
import { j } from "./jstack";
import { clerkWebhookRouter } from "./routers/clerk-webhook-router";
import { pingRouter } from "./routers/ping-router";
import { postRouter } from "./routers/post-router";
import { profileRouter } from "./routers/profile-router";
/**
* This is your base API.
* Here, you can handle errors, not-found responses, cors and more.
*
* @see https://jstack.app/docs/backend/app-router
*/
const api = j
.router()
.basePath("/api")
.use(j.defaults.cors)
.onError(j.defaults.errorHandler);
/**
* This is the main router for your server.
* All routers in /server/routers should be added here manually.
*/
const appRouter = j.mergeRouters(api, {
post: postRouter,
systems: pingRouter,
webhook: clerkWebhookRouter,
profile: profileRouter, // 追加
});
export type AppRouter = typeof appRouter;
export default appRouter;
これでclerkIdからデータを取得できるようになりました
それではPostListの呼び出し方を変えます。
src/app/page.tsx
"use client";
import PostForm from "@/components/PostForm";
import PostList from "@/components/PostList";
import { client } from "@/lib/client";
import { useAuth } from "@clerk/nextjs";
import { useQuery } from "@tanstack/react-query";
import React from "react";
function Home() {
const { userId } = useAuth();
const { data: user } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
if (!userId) return null;
const res = await client.profile.get.$get({ userId });
return await res.json();
},
});
if (!user) {
return div>Loading...div>;
}
return (
div className="max-w-xl mx-auto min-h-screen">
div>
PostForm user={user} />
div>
PostList />
div>
);
}
export default Home;
今回はAPIを叩くときにuserId
を渡しています。
const res = await client.profile.get.$get({ userId });
それでは実際に投稿してみましょう
わかりずらいですが、クリックするとフォームが現れます。
「画像ファイルを選択」を押すと画像も選択できます
「投稿する」をクリックするとタイムラインに投稿が追加されます(画像をアップロードするので時間がかかります)
問題なく投稿できてタイムラインも更新されました。
いまonSuccessがこのようになっています
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts"] });
setContent("");
setImage(null);
},
queryKey
をposts
にしている箇所が再取得されるのですがこのままだと誰かが投稿すると全ユーザーのタイムラインが更新されてしまいます。投稿は1秒に何件も行われるのが通常なので取得が連続で行われてしまいます。
そこでユーザーの認証情報を追加して投稿したユーザーだけが再取得されるようにしましょう
src/componetns/PostForm.tsx
"use client";
import { useState, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import Image from "next/image";
import { UserProfile } from "@/domain/User";
export default function PostForm({ user }: { user: UserProfile }) {
const [content, setContent] = useState("");
const [image, setImage] = useStatestring | null>(null);
const fileInputRef = useRefHTMLInputElement>(null);
const queryClient = useQueryClient();
const createPostMutation = useMutation({
mutationFn: async (newPost: {
content: string;
handle: string;
image?: string;
}) => {
const res = await client.post.create.$post(newPost);
return await res.json();
},
onSuccess: () => {
// 修正
queryClient.invalidateQueries({ queryKey: ["posts", user.handle] });
setContent("");
setImage(null);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim()) {
createPostMutation.mutate({
content,
handle: user.handle,
image: image || undefined,
});
}
};
const handleImageSelect = (e: React.ChangeEventHTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setImage(base64String);
};
reader.readAsDataURL(file);
}
};
const handleImageButtonClick = () => {
fileInputRef.current?.click();
};
return (
form onSubmit={handleSubmit} className="p-4">
div className="flex gap-3">
div className="flex-shrink-0">
{user.avatarUrl ? (
Image
src={user.avatarUrl}
alt={user.name}
width={40}
height={40}
className="w-10 h-10 rounded-full"
/>
) : (
div className="w-10 h-10 rounded-full bg-gray-200">div>
)}
div>
div className="flex-grow">
textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
/>
{image && (
Image src={image} alt="Upload preview" width={500} height={300} />
)}
div>
button type="button" onClick={handleImageButtonClick}>
画像
button>
input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageSelect}
/>
div>
button type="submit">投稿するbutton>
div>
div>
form>
);
}
src/components/PostList.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { Post } from "@/domain/Post";
import { UserProfile } from "@/domain/User";
// 修正
export default function PostList({ user }: { user: UserProfile }) {
const queryClient = useQueryClient();
const { data: posts, isLoading } = useQueryPost[]>({
queryKey: ["posts", user.handle], // 修正
queryFn: async () => {
const res = await client.post.all.$get();
const data = await res.json();
return data as Post[];
},
});
if (isLoading || !posts) {
return p>Loading...p>;
}
return (
div className="divide-y divide-gray-100">
{posts.map((post: Post) => (
div key={post.id}>
p>{post.content}p>
p>{post.like}p>
p>{post.image}p>
p>{post.name}p>
p>{post.handle}p>
div>
))}
div>
);
}
PostListはUserの情報を受け取っていないので受け取るように直しました
export default function PostList({ user }: { user: UserProfile }) {
そしてqueryKey
をposts
とuser.handle
の複合キーにしました
queryKey: ["posts", user.handle],
最後にPostListにユーザーを渡すように直します。
src/app/page.tsx
"use client";
import PostForm from "@/components/PostForm";
import PostList from "@/components/PostList";
import { client } from "@/lib/client";
import { useAuth } from "@clerk/nextjs";
import { useQuery } from "@tanstack/react-query";
import React from "react";
function Home() {
const { userId } = useAuth();
const { data: user } = useQuery({
queryKey: ["user", userId],
queryFn: async () => {
if (!userId) return null;
const res = await client.profile.get.$get({ userId });
return await res.json();
},
});
if (!user) {
return div>Loading...div>;
}
return (
div className="max-w-xl mx-auto min-h-screen">
div>
PostForm user={user} />
div>
PostList user={user} />
div>
);
}
export default Home;
これで全ユーザーが更新されることは防げます。
サイドメニューにスタイルをあてていきます。アイコンにlucide-reactを使うのでインストールします。
shadcn/uiを使っているのでインストールします。
$ npx shadcn@latest add avatar
✔ How would you like to proceed? › Use --legacy-peer-deps
src/components/SideMenu.tsx
"use client";
import Link from "next/link";
import {
Search,
ListTodo,
Bookmark,
User,
Twitter,
LogOut,
} from "lucide-react";
import { useClerk } from "@clerk/nextjs";
import { useState } from "react";
const menuItems = [
{ name: "Explore", icon: Search, href: "/explore" },
{ name: "Lists", icon: ListTodo, href: "/lists" },
{ name: "Bookmarks", icon: Bookmark, href: "/bookmarks" },
{ name: "Profile", icon: User, href: "/profile" },
];
export function SideMenu() {
const { signOut } = useClerk();
const handleLogout = async () => {
await signOut();
};
return (
div className="fixed top-0 left-0 h-screen flex flex-col border-r w-64 py-4 px-2 bg-white z-30 overflow-y-auto shadow-md">
div className="px-4 mb-6">
Link href="https://qiita.com/" className="flex items-center">
Twitter className="h-8 w-8 text-blue-500" />
Link>
div>
nav className="space-y-2 flex-1">
{menuItems.map((item) => (
div key={item.name} className="flex items-center gap-4 px-4 py-3 text-lg rounded-full hover:bg-gray-100 transition-colors font-normal">
item.icon className="h-6 w-6" />
span>{item.name}span>
div>
))}
nav>
div className="mt-auto px-4 mb-6">
button
onClick={handleLogout}
className="w-full text-left px-4 py-3 rounded-full hover:bg-red-100 transition-colors flex items-center gap-3 text-red-600 border border-red-200"
>
LogOut className="h-5 w-5" />
span className="font-medium">ログアウトspan>
button>
div>
div>
);
}
ログアウトを押すとuseCLerkにあるsignOut
が呼ばれてログアウトされます。
const handleLogout = async () => {
await signOut();
};
(省略)
button
onClick={handleLogout}
className="w-full text-left px-4 py-3 rounded-full hover:bg-red-100 transition-colors flex items-center gap-3 text-red-600 border border-red-200"
>
こちらもTailwindCSSとlucide-reactでスタイリングしていきます。
src/components/PostForm.tsx
"use client";
import { useState, useRef } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import Image from "next/image";
import { UserProfile } from "@/domain/User";
import { BarChart, Calendar, FileImage, MapPin, Smile, X } from "lucide-react";
export default function PostForm({ user }: { user: UserProfile }) {
const [content, setContent] = useState("");
const [image, setImage] = useStatestring | null>(null);
const fileInputRef = useRefHTMLInputElement>(null);
const queryClient = useQueryClient();
const createPostMutation = useMutation({
mutationFn: async (newPost: {
content: string;
handle: string;
image?: string;
}) => {
const res = await client.post.create.$post(newPost);
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["posts", user.handle] });
setContent("");
setImage(null);
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (content.trim()) {
createPostMutation.mutate({
content,
handle: user.handle,
image: image || undefined,
});
}
};
const handleImageSelect = (e: React.ChangeEventHTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setImage(base64String);
};
reader.readAsDataURL(file);
}
};
const handleImageButtonClick = () => {
fileInputRef.current?.click();
};
return (
form onSubmit={handleSubmit} className="p-4">
div className="flex gap-3">
div className="flex-shrink-0">
div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold text-lg shadow-md">
{user.name[0]?.toUpperCase()}
div>
div>
div className="flex-grow">
textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="What is happening?!"
className="w-full p-2 text-xl border-none focus:outline-none resize-none min-h-[80px]"
rows={3}
/>
{image && (
div className="relative mt-2 mb-3">
div className="rounded-xl overflow-hidden relative max-h-[300px]">
Image
src={image}
alt="Upload preview"
width={500}
height={300}
className="object-contain max-w-full"
/>
div>
button
type="button"
className="absolute top-2 right-2 bg-gray-800 bg-opacity-70 text-white rounded-full p-1"
>
X size={16} />
button>
div>
)}
div className="border-t border-gray-100 pt-3 flex justify-between items-center">
div className="flex gap-2 text-blue-500">
button
type="button"
onClick={handleImageButtonClick}
className="p-2 rounded-full hover:bg-blue-50"
>
FileImage size={18} />
button>
input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageSelect}
/>
button
type="button"
className="p-2 rounded-full hover:bg-blue-50"
>
BarChart size={18} />
button>
button
type="button"
className="p-2 rounded-full hover:bg-blue-50"
>
Smile size={18} />
button>
button
type="button"
className="p-2 rounded-full hover:bg-blue-50"
>
Calendar size={18} />
button>
button
type="button"
className="p-2 rounded-full hover:bg-blue-50"
>
MapPin size={18} />
button>
div>
button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded-full font-medium hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
>
{createPostMutation.isPending ? "Posting..." : "Post"}
button>
div>
div>
div>
form>
);
}
いい感じになりました。画像をつけてるとよりクローンアプリ感があります。
投稿1つ1つはコンポーネントにして使い回せるようにします。
$ touch src/components/PostItem.tsx
src/components/PostItem.tsx
"use client";
import { Post } from "@/domain/Post";
import { Button } from "@/components/ui/button";
import { MessageCircle, Repeat, Heart, Share } from "lucide-react";
import Image from "next/image";
interface PostItemProps {
post: Post;
}
export function PostItem({ post }: PostItemProps) {
return (
div className="hover:bg-gray-50 transition-colors px-4 py-3">
div className="flex space-x-3">
div className="flex-shrink-0">
div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold text-lg shadow-md">
{currentUserHandle?.[0]?.toUpperCase()}
div>
div>
div className="flex-1 min-w-0">
div className="flex items-center text-sm">
span className="font-bold text-gray-900 mr-1">
{post.name || post.handle}
span>
span className="text-gray-500 mr-1">@{post.handle}span>
span className="text-gray-500">
· {new Date(post.createdAt).toLocaleDateString()}
span>
div>
p className="mt-1 text-gray-900">{post.content}p>
{post.image && (
div className="mt-2 rounded-xl overflow-hidden relative max-h-[300px]">
Image
src={post.image}
alt="Post image"
width={500}
height={300}
className="object-contain max-w-full"
/>
div>
)}
div className="flex justify-between max-w-md mt-3">
Button
variant="ghost"
size="sm"
className="h-8 px-0 text-gray-500 hover:text-blue-500"
>
MessageCircle className="h-[18px] w-[18px]" />
span className="ml-2 text-xs">0span>
Button>
Button
variant="ghost"
size="sm"
className="h-8 px-0 text-gray-500 hover:text-green-500"
>
Repeat className="h-[18px] w-[18px]" />
span className="ml-2 text-xs">0span>
Button>
Button
variant="ghost"
size="sm"
className="h-8 px-0 text-gray-500 hover:text-red-500"
>
Heart className="h-[18px] w-[18px]" />
span className="ml-2 text-xs">{post.like}span>
Button>
Button
variant="ghost"
size="sm"
className="h-8 px-0 text-gray-500 hover:text-blue-500"
>
Share className="h-[18px] w-[18px]" />
Button>
div>
div>
div>
div>
);
}
このコンポーネントをPostList
で使ってみましょう
src/components/PostList.tsx
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { client } from "@/lib/client";
import { Post } from "@/domain/Post";
import { UserProfile } from "@/domain/User";
import { PostItem } from "./PostItem";
export default function PostList({ user }: { user: UserProfile }) {
const queryClient = useQueryClient();
const { data: posts, isLoading } = useQueryPost[]>({
queryKey: ["posts", user.handle],
queryFn: async () => {
const res = await client.post.all.$get();
const data = await res.json();
return data as Post[];
},
});
if (isLoading) {
return p className="text-center py-4">Loading...p>;
}
if (!posts || posts.length === 0) {
return (
p className="text-center py-4">No posts yet. Be the first to post!p>
);
}
return (
div className="divide-y divide-gray-100">
{posts.map((post: Post) => (
PostItem key={post.id} post={post} />
))}
div>
);
}
いい感じになったので画像つきの投稿が表示されるかを確認します。
すると以下のエラーが出ました。
Next.jsのnext/imageコンポーネントは、セキュリティ上の理由から、デフォルトでは外部ドメインからの画像の読み込みを制限しています。そこでCloudinaryから読み込みできるように設定を変えましょう
next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["res.cloudinary.com"],
},
};
export default nextConfig;
これで画像のポストが表示できました。
ここまでで今回のハンズオンは終了となります。
よりTwitterクローンにするための課題を用意しましたのでここまでの内容を踏まえて開発してみてください
1. いいね機能をつける
いいねをつけられるようにしてください。
同じ投稿にいいねは1つまでしかつけられず、オンオフできるように実装してください
2. プロフィール画面の実装
プロフィール画面を実装してください (/profile)
画像、ハンドルネーム、自己紹介を変更できるようにしてください。
またタイムラインに自分の投稿と自分がいいねした投稿をタイムラインに表示してください
3. ユーザーアイコンの表示
タイムラインのそれぞれの投稿に投稿したユーザーのアバター画像を使うように修正してください
今回は次世代スタックであるJStackについて解説しました!
簡単に使える仕組みがあるので従うだけで素早く型安全に開発ができました。
テキストでは細かく解説しましたが、一部説明しきれていない箇所もありますので動画教材で紹介しています。合わせてご利用ください
次回のハンズオンのレビュアーはXにて募集します。
Views: 0
東京ディズニーランドにおいて4月1日より販売されている「ドナルドのクワッキー・ダック!ダック!ダックシティ!」スペシャルメニューを撮り下ろしで紹介する。
4月8日から6月30日まで開催されるイベント「ドナルドのクワッキー・ダック!ダック!ダックシティ!」をイメージしたにぎやかなスペシャルメニューが登場。ドナルドが夢に描いたユニークな世界観をメニューでも楽しむことができる。
パーク内の各レストランやカフェではスーベニア付きのフード・ドリンクが用意されるほか、ストラップ付きで可愛いクワッキーダックのミニスナックケースも販売される。
Views: 0
2025年3月26日にOpenAIは、ChatGPTで利用可能な画像生成機能として「4o Image Generation」を実装しました。新たに、OpenAIが4o Image Generationで生成された画像にウォーターマーク(透かし)を入れるテストを行っていることが報じられています。
OpenAI tests watermarking for ChatGPT-4o Image Generation model
https://www.bleepingcomputer.com/news/artificial-intelligence/openai-tests-watermarking-for-chatgpt-4o-image-generation-model/
OpenAI Is Testing Watermarks for Its GPT-4o Image Generation Mode – WinBuzzer
https://winbuzzer.com/2025/04/06/openai-expands-chatgpt-4o-image-generation-tool-with-watermark-experiments-xcxwbn/
2025年3月26日に公開された4o Image GenerationはGPT-4oの知識を活用して高精度な画像を生成できる上に、AIと対話しながら生成される画像のクオリティを向上させることも可能です。
4o Image Generationは公開直後から人気を博し、2025年4月4日には、OpenAIの最高執行責任者であるブラッド・ライトキャップ氏が「先週の火曜日(3月26日)以来、1億3000万人を超えるユーザーが、7億枚以上の画像を生成しました」と報告しています。
ChatGPTの新たな画像生成機能を1週間で1億3000万人が使って7億枚以上を生成 – GIGAZINE
その一方で、4o Image Generationを使ったスタジオジブリ風のミーム画像が大量に生成されており、ghiblifying(ジブリ化)という言葉が使用されるようになったほか、著作権侵害問題や倫理的な問題による議論が巻き起こっています。
ChatGPTにGPT-4oでの画像生成機能が実装されスタジオジブリ風のミーム画像が大量生成されるようになり著作権問題が浮き彫りに – GIGAZINE
こうした状況に対応するためか、OpenAIは生成された画像にウォーターマークを入れるテストを実施しているとのことです。AI研究者のティボール・ブラホ氏は、Android版ChatGPTアプリのバージョン1.2025.091 2509108のベータ版において「image-gen-watermark-for-free」との記載があることを発見しました。
ChatGPT updates
– Student Plus referral program now also available for Colombian students (Universidad Nacional de Colombia)
– new mentions of “shared posts” in addition to shared conversation, canvas and deep research in the web app
– the new ImageGen watermark is mentioned… pic.twitter.com/j4sYfWJXLB
— Tibor Blaho (@btibor91) April 5, 2025
また、「for-free」と記載されていることから、ウォーターマークが入るのはChatGPTの無料プランを利用するユーザーのみになるようです。有料プランに加入しているユーザーは、「Save image without watermark」を選択することでウォーターマークなしで4o Image Generationで生成された画像を保存することが可能だそうです。
なお、今回のテストはあくまでベータ版であるため、実際にOpenAIが4o Image Generationにウォーターマークを導入するかどうかは不明です。
この記事のタイトルとURLをコピーする
Views: 0
銀座のスタジオからゲーム内の最新情報や実機プレイをお披露目。9日まで観覧者を募集しており、当選すれば現地でも観覧できる
Source link
Views: 0
『マインクラフト』実写映画にHIKAKINやゲーム実況者も参戦!?4月25日全国公開です。
Source link
Views: 0
アニプレックスは本日,2025年8月1日に発売を予定している「鬼滅の刃ヒノカミ血風譚2」にプレイアブルキャラとして登場する,鬼殺隊士「不死川玄弥」と,上弦の鬼「玉壺」「憎珀天」の紹介映像を公開した。映像では,各キャラクターのバトルシーンや奥義を確認できる。
Source link
Views: 0
Bungie新作PvP脱出シューター『Marathon』ついに沈黙破る!ゲームプレイ映像が4月13日にお披露目―謎のメカネコちゃんも
Source link
Views: 0