diff --git a/oauth_new/.env b/oauth_new/.env
new file mode 100644
index 0000000..cada505
--- /dev/null
+++ b/oauth_new/.env
@@ -0,0 +1,6 @@
+VITE_ADMIN=ai.syui.ai
+VITE_PDS=syu.is
+VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
+VITE_COLLECTION=ai.syui.log
+VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
+VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
diff --git a/oauth_new/DEVELOPMENT.md b/oauth_new/DEVELOPMENT.md
new file mode 100644
index 0000000..e49c2af
--- /dev/null
+++ b/oauth_new/DEVELOPMENT.md
@@ -0,0 +1,334 @@
+# 開発ガイド
+
+## 設計思想
+
+このプロジェクトは以下の原則に基づいて設計されています:
+
+### 1. 環境変数による設定の外部化
+- ハードコードを避け、設定は全て環境変数で管理
+- `src/config/env.js` で一元管理
+
+### 2. PDS(Personal Data Server)の自動判定
+- `VITE_HANDLE_LIST` と `VITE_PDS` による自動判定
+- syu.is系とbsky.social系の自動振り分け
+
+### 3. コンポーネントの責任分離
+- Hooks: ビジネスロジック
+- Components: UI表示のみ
+- Services: 外部API連携
+- Utils: 純粋関数
+
+## アーキテクチャ詳細
+
+### データフロー
+
+```
+User Input
+ ↓
+Hooks (useAuth, useAdminData, usePageContext)
+ ↓
+Services (OAuthService)
+ ↓
+API (atproto.js)
+ ↓
+ATProto Network
+ ↓
+Components (UI Display)
+```
+
+### 状態管理
+
+React Hooksによる状態管理:
+- `useAuth`: OAuth認証状態
+- `useAdminData`: 管理者データ(プロフィール、レコード)
+- `usePageContext`: ページ判定(トップ/個別)
+
+### OAuth認証フロー
+
+```
+1. ユーザーがハンドル入力
+2. PDS判定 (syu.is vs bsky.social)
+3. 適切なOAuthClientを選択
+4. 標準OAuth画面にリダイレクト
+5. 認証完了後コールバック処理
+6. セッション復元・保存
+```
+
+## 重要な実装詳細
+
+### セッション管理
+
+`@atproto/oauth-client-browser`が自動的に以下を処理:
+- IndexedDBへのセッション保存
+- トークンの自動更新
+- DPoP(Demonstration of Proof of Possession)
+
+**注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。
+
+### PDS判定アルゴリズム
+
+```javascript
+// src/utils/pds.js
+function isSyuIsHandle(handle) {
+ return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
+}
+```
+
+1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is
+2. `.syu.is` で終わるハンドル → syu.is
+3. その他 → bsky.social
+
+### レコードフィルタリング
+
+```javascript
+// src/components/RecordTabs.jsx
+const filterRecords = (records) => {
+ if (pageContext.isTopPage) {
+ return records.slice(0, 3) // 最新3件
+ } else {
+ // URL のrkey と record.value.post.url のrkey を照合
+ return records.filter(record => {
+ const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '')
+ return recordRkey === pageContext.rkey
+ })
+ }
+}
+```
+
+## 開発時の注意点
+
+### 1. 環境変数の命名
+
+- `VITE_` プレフィックス必須(Viteの制約)
+- JSON形式の環境変数は文字列として定義
+
+```bash
+# ❌ 間違い
+VITE_HANDLE_LIST=["ai.syui.ai"]
+
+# ✅ 正しい
+VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"]
+```
+
+### 2. API エラーハンドリング
+
+```javascript
+// src/api/atproto.js
+async function request(url) {
+ const response = await fetch(url)
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`)
+ }
+ return await response.json()
+}
+```
+
+すべてのAPI呼び出しでエラーハンドリングを実装。
+
+### 3. コンポーネント設計
+
+```javascript
+// ❌ Bad: ビジネスロジックがコンポーネント内
+function MyComponent() {
+ const [data, setData] = useState([])
+ useEffect(() => {
+ fetch('/api/data').then(setData)
+ }, [])
+ return
{data.map(...)}
+}
+
+// ✅ Good: Hooksでロジック分離
+function MyComponent() {
+ const { data, loading, error } = useMyData()
+ if (loading) return
+ if (error) return
+ return {data.map(...)}
+}
+```
+
+## デバッグ手法
+
+### 1. OAuth デバッグ
+
+```javascript
+// ブラウザの開発者ツールで確認
+localStorage.clear() // セッションクリア
+sessionStorage.clear() // 一時データクリア
+
+// IndexedDB確認(Application タブ)
+// ATProtoの認証データが保存される
+```
+
+### 2. PDS判定デバッグ
+
+```javascript
+// src/utils/pds.js にログ追加
+console.log('Handle:', handle)
+console.log('Is syu.is:', isSyuIsHandle(handle))
+console.log('API Config:', getApiConfig(pds))
+```
+
+### 3. レコードフィルタリングデバッグ
+
+```javascript
+// src/components/RecordTabs.jsx
+console.log('Page Context:', pageContext)
+console.log('All Records:', records.length)
+console.log('Filtered Records:', filteredRecords.length)
+```
+
+## パフォーマンス最適化
+
+### 1. 並列データ取得
+
+```javascript
+// src/hooks/useAdminData.js
+const [records, lang, comment] = await Promise.all([
+ collections.getBase(apiConfig.pds, did, env.collection),
+ collections.getLang(apiConfig.pds, did, env.collection),
+ collections.getComment(apiConfig.pds, did, env.collection)
+])
+```
+
+### 2. 不要な再レンダリング防止
+
+```javascript
+// useMemo でフィルタリング結果をキャッシュ
+const filteredRecords = useMemo(() =>
+ filterRecords(records),
+ [records, pageContext]
+)
+```
+
+## テスト戦略
+
+### 1. 単体テスト推奨対象
+
+- `src/utils/pds.js` - PDS判定ロジック
+- `src/config/env.js` - 環境変数パース
+- フィルタリング関数
+
+### 2. 統合テスト推奨対象
+
+- OAuth認証フロー
+- API呼び出し
+- レコード表示
+
+## デプロイメント
+
+### 1. 必要ファイル
+
+```
+public/
+└── client-metadata.json # OAuth設定ファイル
+
+dist/ # ビルド出力
+├── index.html
+└── assets/
+ ├── comment-atproto-[hash].js
+ └── comment-atproto-[hash].css
+```
+
+### 2. デプロイ手順
+
+```bash
+# 1. 環境変数設定
+cp .env.example .env
+# 2. 本番用設定を記入
+# 3. ビルド
+npm run build
+# 4. dist/ フォルダをデプロイ
+```
+
+### 3. 本番環境チェックリスト
+
+- [ ] `.env` ファイルの本番設定
+- [ ] `client-metadata.json` の設置
+- [ ] HTTPS 必須(OAuth要件)
+- [ ] CSP(Content Security Policy)設定
+
+## よくある問題と解決法
+
+### 1. "OAuth initialization failed"
+
+**原因**: client-metadata.json が見つからない、または形式が正しくない
+
+**解決法**:
+```bash
+# public/client-metadata.json の存在確認
+ls -la public/client-metadata.json
+
+# 形式確認(JSON validation)
+jq . public/client-metadata.json
+```
+
+### 2. "Failed to load admin data"
+
+**原因**: 管理者アカウントのDID解決に失敗
+
+**解決法**:
+```bash
+# 手動でDID解決確認
+curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai"
+```
+
+### 3. レコードが表示されない
+
+**原因**: コレクション名の不一致、権限不足
+
+**解決法**:
+```bash
+# コレクション確認
+curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang"
+```
+
+## 機能拡張ガイド
+
+### 1. 新しいコレクション追加
+
+```javascript
+// src/api/atproto.js に追加
+export const collections = {
+ // 既存...
+ async getNewCollection(pds, repo, collection, limit = 10) {
+ return await atproto.getRecords(pds, repo, `${collection}.new`, limit)
+ }
+}
+```
+
+### 2. 新しいPDS対応
+
+```javascript
+// src/utils/pds.js を拡張
+export function getApiConfig(pds) {
+ if (pds.includes('syu.is')) {
+ // 既存の syu.is 設定
+ } else if (pds.includes('newpds.com')) {
+ return {
+ pds: `https://newpds.com`,
+ bsky: `https://bsky.newpds.com`,
+ plc: `https://plc.newpds.com`,
+ web: `https://web.newpds.com`
+ }
+ }
+ // デフォルト設定
+}
+```
+
+### 3. リアルタイム更新追加
+
+```javascript
+// src/hooks/useRealtimeUpdates.js
+export function useRealtimeUpdates(collection) {
+ useEffect(() => {
+ const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
+ ws.onmessage = (event) => {
+ const data = JSON.parse(event.data)
+ if (data.collection === collection) {
+ // 新しいレコードを追加
+ }
+ }
+ return () => ws.close()
+ }, [collection])
+}
+```
\ No newline at end of file
diff --git a/oauth_new/README.md b/oauth_new/README.md
new file mode 100644
index 0000000..f369473
--- /dev/null
+++ b/oauth_new/README.md
@@ -0,0 +1,222 @@
+# ATProto OAuth Comment System
+
+ATProtocol(Bluesky)のOAuth認証を使用したコメントシステムです。
+
+## プロジェクト概要
+
+このプロジェクトは、ATProtocolネットワーク上のコメントとlangレコードを表示するWebアプリケーションです。
+- 標準的なOAuth認証画面を使用
+- タブ切り替えでレコード表示
+- ページコンテキストに応じたフィルタリング
+
+## ファイル構成
+
+```
+src/
+├── config/
+│ └── env.js # 環境変数の一元管理
+├── utils/
+│ └── pds.js # PDS判定・API設定ユーティリティ
+├── api/
+│ └── atproto.js # ATProto API クライアント
+├── hooks/
+│ ├── useAuth.js # OAuth認証フック
+│ ├── useAdminData.js # 管理者データ取得フック
+│ └── usePageContext.js # ページ判定フック
+├── services/
+│ └── oauth.js # OAuth認証サービス
+├── components/
+│ ├── AuthButton.jsx # ログイン/ログアウトボタン
+│ ├── RecordTabs.jsx # Lang/Commentタブ切り替え
+│ ├── RecordList.jsx # レコード表示リスト
+│ ├── UserLookup.jsx # ユーザー検索(未使用)
+│ └── OAuthCallback.jsx # OAuth コールバック処理
+└── App.jsx # メインアプリケーション
+```
+
+## 環境設定
+
+### .env ファイル
+
+```bash
+VITE_ADMIN=ai.syui.ai # 管理者ハンドル
+VITE_PDS=syu.is # デフォルトPDS
+VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"] # syu.is系ハンドルリスト
+VITE_COLLECTION=ai.syui.log # ベースコレクション
+VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json # OAuth クライアントID
+VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback # OAuth リダイレクトURI
+```
+
+### 必要な依存関係
+
+```json
+{
+ "dependencies": {
+ "@atproto/api": "^0.15.12",
+ "@atproto/oauth-client-browser": "^0.3.19",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
+ }
+}
+```
+
+## 主要機能
+
+### 1. OAuth認証システム
+
+**実装場所**: `src/services/oauth.js`
+
+- `@atproto/oauth-client-browser`を使用した標準OAuth実装
+- bsky.social と syu.is 両方のPDSに対応
+- セッション自動復元機能
+
+**重要**: ATProtoのセッション管理は複雑なため、公式ライブラリの使用が必須です。
+
+### 2. PDS判定システム
+
+**実装場所**: `src/utils/pds.js`
+
+```javascript
+// ハンドル判定ロジック
+isSyuIsHandle(handle) → boolean
+// PDS設定取得
+getApiConfig(pds) → { pds, bsky, plc, web }
+```
+
+環境変数`VITE_HANDLE_LIST`と`VITE_PDS`を基に自動判定します。
+
+### 3. コレクション取得システム
+
+**実装場所**: `src/api/atproto.js`
+
+```javascript
+// 基本コレクション
+collections.getBase(pds, repo, collection)
+// lang コレクション(翻訳系)
+collections.getLang(pds, repo, collection) // → {collection}.chat.lang
+// comment コレクション(コメント系)
+collections.getComment(pds, repo, collection) // → {collection}.chat.comment
+```
+
+### 4. ページコンテキスト判定
+
+**実装場所**: `src/hooks/usePageContext.js`
+
+```javascript
+// URL解析結果
+{
+ isTopPage: boolean, // トップページかどうか
+ rkey: string | null, // 個別ページのrkey(/posts/xxx → xxx)
+ url: string // 現在のURL
+}
+```
+
+## 表示ロジック
+
+### フィルタリング
+
+1. **トップページ**: 最新3件を表示
+2. **個別ページ**: `record.value.post.url`の rkey が現在ページと一致するもののみ表示
+
+### タブ切り替え
+
+- Lang Records: `{collection}.chat.lang`
+- Comment Records: `{collection}.chat.comment`
+
+## 開発・デバッグ
+
+### 起動コマンド
+
+```bash
+npm install
+npm run dev # 開発サーバー
+npm run build # プロダクションビルド
+```
+
+### OAuth デバッグ
+
+1. **ローカル開発**: 自動的にloopback clientが使用される
+2. **本番環境**: `client-metadata.json`が必要
+
+```json
+// public/client-metadata.json
+{
+ "client_id": "https://syui.ai/client-metadata.json",
+ "client_name": "ATProto Comment System",
+ "redirect_uris": ["https://syui.ai/oauth/callback"],
+ "scope": "atproto",
+ "grant_types": ["authorization_code", "refresh_token"],
+ "response_types": ["code"],
+ "token_endpoint_auth_method": "none",
+ "application_type": "web",
+ "dpop_bound_access_tokens": true
+}
+```
+
+### よくある問題
+
+1. **セッションが保存されない**
+ - `@atproto/oauth-client-browser`のバージョン確認
+ - IndexedDBの確認(ブラウザの開発者ツール)
+
+2. **PDS判定が正しく動作しない**
+ - `VITE_HANDLE_LIST`の JSON 形式を確認
+ - 環境変数の読み込み確認
+
+3. **レコードが表示されない**
+ - 管理者アカウントの DID 解決確認
+ - コレクション名の確認(`{base}.chat.lang`, `{base}.chat.comment`)
+
+## API エンドポイント
+
+### 使用しているATProto API
+
+1. **com.atproto.repo.describeRepo**
+ - ハンドル → DID, PDS解決
+
+2. **app.bsky.actor.getProfile**
+ - プロフィール情報取得
+
+3. **com.atproto.repo.listRecords**
+ - コレクションレコード取得
+
+## セキュリティ
+
+- OAuth 2.1 + PKCE による認証
+- DPoP (Demonstration of Proof of Possession) 対応
+- セッション情報はブラウザのIndexedDBに暗号化保存
+
+## 今後の拡張可能性
+
+1. **コメント投稿機能**
+ - 認証済みユーザーによるコメント作成
+ - `com.atproto.repo.putRecord` API使用
+
+2. **リアルタイム更新**
+ - Jetstream WebSocket 接続
+ - 新しいレコードの自動表示
+
+3. **マルチPDS対応**
+ - より多くのPDSへの対応
+ - 動的PDS判定の改善
+
+## トラブルシューティング
+
+### ログ確認
+ブラウザの開発者ツールでコンソールログを確認してください。主要なエラーは以下の通りです:
+
+- `OAuth initialization failed`: OAuth設定の問題
+- `Failed to load admin data`: API アクセスエラー
+- `Auth check failed`: セッション復元エラー
+
+### 環境変数確認
+```javascript
+// 開発者ツールのコンソールで確認
+console.log(import.meta.env)
+```
+
+## 参考資料
+
+- [ATProto OAuth Guide](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md)
+- [BrowserOAuthClient Documentation](https://github.com/bluesky-social/atproto/tree/main/packages/oauth-client-browser)
+- [ATProto API Reference](https://docs.bsky.app/docs/advanced-guides/atproto-api)
\ No newline at end of file
diff --git a/oauth_new/index.html b/oauth_new/index.html
new file mode 100644
index 0000000..5664ef4
--- /dev/null
+++ b/oauth_new/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Comments Test
+
+
+
+
+
+
\ No newline at end of file
diff --git a/oauth_new/package.json b/oauth_new/package.json
new file mode 100644
index 0000000..e0ead1d
--- /dev/null
+++ b/oauth_new/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "oauth-simple",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "@atproto/api": "^0.15.12",
+ "@atproto/oauth-client-browser": "^0.3.19"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@vitejs/plugin-react": "^4.0.0",
+ "vite": "^5.0.0"
+ }
+}
\ No newline at end of file
diff --git a/oauth_new/src/App.jsx b/oauth_new/src/App.jsx
new file mode 100644
index 0000000..598db75
--- /dev/null
+++ b/oauth_new/src/App.jsx
@@ -0,0 +1,76 @@
+import React from 'react'
+import { useAuth } from './hooks/useAuth.js'
+import { useAdminData } from './hooks/useAdminData.js'
+import { useUserData } from './hooks/useUserData.js'
+import { usePageContext } from './hooks/usePageContext.js'
+import AuthButton from './components/AuthButton.jsx'
+import RecordTabs from './components/RecordTabs.jsx'
+import CommentForm from './components/CommentForm.jsx'
+import OAuthCallback from './components/OAuthCallback.jsx'
+
+export default function App() {
+ const { user, agent, loading: authLoading, login, logout } = useAuth()
+ const { adminData, langRecords, commentRecords, loading: dataLoading, error, refresh: refreshAdminData } = useAdminData()
+ const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
+ const pageContext = usePageContext()
+
+ // Handle OAuth callback
+ if (window.location.search.includes('code=')) {
+ return
+ }
+
+ const isLoading = authLoading || dataLoading || userLoading
+
+ if (isLoading) {
+ return (
+
+
ATProto OAuth Demo
+
読み込み中...
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
ATProto OAuth Demo
+
エラー: {error}
+
+
+ )
+ }
+
+ return (
+
+
+
+
{
+ refreshAdminData?.()
+ refreshUserData?.()
+ }}
+ />
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/oauth_new/src/api/atproto.js b/oauth_new/src/api/atproto.js
new file mode 100644
index 0000000..6a65249
--- /dev/null
+++ b/oauth_new/src/api/atproto.js
@@ -0,0 +1,80 @@
+// ATProto API client
+const ENDPOINTS = {
+ describeRepo: 'com.atproto.repo.describeRepo',
+ getProfile: 'app.bsky.actor.getProfile',
+ listRecords: 'com.atproto.repo.listRecords',
+ putRecord: 'com.atproto.repo.putRecord'
+}
+
+async function request(url, options = {}) {
+ const response = await fetch(url, options)
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`)
+ }
+ return await response.json()
+}
+
+export const atproto = {
+ async getDid(pds, handle) {
+ const res = await request(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
+ return res.did
+ },
+
+ async getProfile(bsky, actor) {
+ return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
+ },
+
+ async getRecords(pds, repo, collection, limit = 10) {
+ const res = await request(`${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`)
+ return res.records || []
+ },
+
+ async searchPlc(plc, did) {
+ try {
+ const data = await request(`${plc}/${did}`)
+ return {
+ success: true,
+ endpoint: data?.service?.[0]?.serviceEndpoint || null,
+ handle: data?.alsoKnownAs?.[0]?.replace('at://', '') || null
+ }
+ } catch {
+ return { success: false, endpoint: null, handle: null }
+ }
+ },
+
+ async putRecord(pds, record, agent) {
+ if (!agent) {
+ throw new Error('Agent required for putRecord')
+ }
+
+ // Use Agent's putRecord method instead of direct fetch
+ return await agent.com.atproto.repo.putRecord(record)
+ }
+}
+
+// Collection specific methods
+export const collections = {
+ async getBase(pds, repo, collection, limit = 10) {
+ return await atproto.getRecords(pds, repo, collection, limit)
+ },
+
+ async getLang(pds, repo, collection, limit = 10) {
+ return await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
+ },
+
+ async getComment(pds, repo, collection, limit = 10) {
+ return await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
+ },
+
+ async getChat(pds, repo, collection, limit = 10) {
+ return await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
+ },
+
+ async getUserList(pds, repo, collection, limit = 100) {
+ return await atproto.getRecords(pds, repo, `${collection}.user`, limit)
+ },
+
+ async getUserComments(pds, repo, collection, limit = 10) {
+ return await atproto.getRecords(pds, repo, collection, limit)
+ }
+}
\ No newline at end of file
diff --git a/oauth_new/src/components/AuthButton.jsx b/oauth_new/src/components/AuthButton.jsx
new file mode 100644
index 0000000..f2d6e27
--- /dev/null
+++ b/oauth_new/src/components/AuthButton.jsx
@@ -0,0 +1,102 @@
+import React, { useState } from 'react'
+
+export default function AuthButton({ user, onLogin, onLogout, loading }) {
+ const [handleInput, setHandleInput] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!handleInput.trim() || isLoading) return
+
+ setIsLoading(true)
+ try {
+ await onLogin(handleInput.trim())
+ } catch (error) {
+ console.error('Login failed:', error)
+ alert('ログインに失敗しました: ' + error.message)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (loading) {
+ return 認証状態を確認中...
+ }
+
+ if (user) {
+ return (
+
+
ログイン中: {user.handle}
+
+
+
+ )
+ }
+
+ return (
+
+
OAuth認証
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/oauth_new/src/components/CommentForm.jsx b/oauth_new/src/components/CommentForm.jsx
new file mode 100644
index 0000000..15131ad
--- /dev/null
+++ b/oauth_new/src/components/CommentForm.jsx
@@ -0,0 +1,200 @@
+import React, { useState } from 'react'
+import { atproto } from '../api/atproto.js'
+import { env } from '../config/env.js'
+
+export default function CommentForm({ user, agent, onCommentPosted }) {
+ const [text, setText] = useState('')
+ const [url, setUrl] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!text.trim() || !url.trim()) return
+
+ setLoading(true)
+ setError(null)
+
+ try {
+ // Create ai.syui.log record structure
+ const record = {
+ repo: user.did,
+ collection: env.collection,
+ rkey: `comment-${Date.now()}`,
+ record: {
+ $type: env.collection,
+ url: url.trim(),
+ comments: [
+ {
+ url: url.trim(),
+ text: text.trim(),
+ author: {
+ did: user.did,
+ handle: user.handle,
+ displayName: user.displayName,
+ avatar: user.avatar
+ },
+ createdAt: new Date().toISOString()
+ }
+ ],
+ createdAt: new Date().toISOString()
+ }
+ }
+
+ // Post the record
+ await atproto.putRecord(null, record, agent)
+
+ // Clear form
+ setText('')
+ setUrl('')
+
+ // Notify parent component
+ if (onCommentPosted) {
+ onCommentPosted()
+ }
+
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (!user) {
+ return (
+
+ )
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/oauth_new/src/components/OAuthCallback.jsx b/oauth_new/src/components/OAuthCallback.jsx
new file mode 100644
index 0000000..f61b444
--- /dev/null
+++ b/oauth_new/src/components/OAuthCallback.jsx
@@ -0,0 +1,50 @@
+import React, { useEffect, useState } from 'react'
+
+export default function OAuthCallback({ onAuthSuccess }) {
+ const [status, setStatus] = useState('OAuth認証処理中...')
+
+ useEffect(() => {
+ handleCallback()
+ }, [])
+
+ const handleCallback = async () => {
+ try {
+ // BrowserOAuthClientが自動的にコールバックを処理します
+ // URLのパラメータを確認して成功を通知
+ const urlParams = new URLSearchParams(window.location.search)
+ const code = urlParams.get('code')
+ const error = urlParams.get('error')
+
+ if (error) {
+ throw new Error(`OAuth error: ${error}`)
+ }
+
+ if (code) {
+ setStatus('認証成功!メインページに戻ります...')
+
+ // 少し待ってからメインページにリダイレクト
+ setTimeout(() => {
+ window.location.href = '/'
+ }, 1500)
+ } else {
+ setStatus('認証情報が見つかりません')
+ }
+
+ } catch (error) {
+ console.error('Callback error:', error)
+ setStatus('認証エラー: ' + error.message)
+ }
+ }
+
+ return (
+
+
OAuth認証
+
{status}
+ {status.includes('エラー') && (
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/oauth_new/src/components/RecordList.jsx b/oauth_new/src/components/RecordList.jsx
new file mode 100644
index 0000000..3949459
--- /dev/null
+++ b/oauth_new/src/components/RecordList.jsx
@@ -0,0 +1,58 @@
+import React from 'react'
+
+export default function RecordList({ title, records, apiConfig, showTitle = true }) {
+ if (!records || records.length === 0) {
+ return (
+
+ {showTitle && {title} (0)
}
+ レコードがありません
+
+ )
+ }
+
+ return (
+
+ {showTitle && {title} ({records.length})
}
+ {records.map((record, i) => (
+
+ {record.value.author?.avatar && (
+

+ )}
+
{record.value.author?.displayName || record.value.author?.handle}
+
+
{record.value.text || record.value.content}
+ {record.value.post?.url && (
+
+ )}
+
+ {new Date(record.value.createdAt).toLocaleString()}
+
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/oauth_new/src/components/RecordTabs.jsx b/oauth_new/src/components/RecordTabs.jsx
new file mode 100644
index 0000000..8ae0133
--- /dev/null
+++ b/oauth_new/src/components/RecordTabs.jsx
@@ -0,0 +1,151 @@
+import React, { useState } from 'react'
+import RecordList from './RecordList.jsx'
+
+export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
+ const [activeTab, setActiveTab] = useState('lang')
+
+ // Filter records based on page context
+ const filterRecords = (records) => {
+ if (pageContext.isTopPage) {
+ // Top page: show latest 3 records
+ return records.slice(0, 3)
+ } else {
+ // Individual page: show records matching the URL
+ return records.filter(record => {
+ const recordUrl = record.value.post?.url
+ if (!recordUrl) return false
+
+ try {
+ const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
+ return recordRkey === pageContext.rkey
+ } catch {
+ return false
+ }
+ })
+ }
+ }
+
+ const filteredLangRecords = filterRecords(langRecords)
+ const filteredCommentRecords = filterRecords(commentRecords)
+ const filteredUserComments = filterRecords(userComments || [])
+ const filteredChatRecords = filterRecords(chatRecords || [])
+
+ return (
+
+
+
+
+
+
+
+
+
+ {activeTab === 'lang' && (
+
+ )}
+ {activeTab === 'comment' && (
+
+ )}
+ {activeTab === 'collection' && (
+
+ )}
+ {activeTab === 'users' && (
+
+ )}
+
+
+
+
+ {pageContext.isTopPage
+ ? "トップページ: 最新3件を表示"
+ : `個別ページ: ${pageContext.rkey} に関連するレコードを表示`
+ }
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/oauth_new/src/components/UserLookup.jsx b/oauth_new/src/components/UserLookup.jsx
new file mode 100644
index 0000000..c9d4354
--- /dev/null
+++ b/oauth_new/src/components/UserLookup.jsx
@@ -0,0 +1,115 @@
+import React, { useState } from 'react'
+import { atproto } from '../api/atproto.js'
+import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
+
+export default function UserLookup() {
+ const [handleInput, setHandleInput] = useState('')
+ const [userInfo, setUserInfo] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!handleInput.trim() || loading) return
+
+ setLoading(true)
+ try {
+ const userPds = await getPdsFromHandle(handleInput)
+ const apiConfig = getApiConfig(userPds)
+ const did = await atproto.getDid(userPds.replace('https://', ''), handleInput)
+ const profile = await atproto.getProfile(apiConfig.bsky, did)
+
+ setUserInfo({
+ handle: handleInput,
+ pds: userPds,
+ did,
+ profile,
+ config: apiConfig
+ })
+ } catch (error) {
+ console.error('User lookup failed:', error)
+ setUserInfo({ error: error.message })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ ユーザー検索
+
+
+ {userInfo && (
+
+
ユーザー情報:
+ {userInfo.error ? (
+
エラー: {userInfo.error}
+ ) : (
+
+
Handle: {userInfo.handle}
+
PDS: {userInfo.pds}
+
DID: {userInfo.did}
+
Display Name: {userInfo.profile?.displayName}
+
PDS API: {userInfo.config?.pds}
+
Bsky API: {userInfo.config?.bsky}
+
Web: {userInfo.config?.web}
+
+ )}
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/oauth_new/src/config/env.js b/oauth_new/src/config/env.js
new file mode 100644
index 0000000..eea1efa
--- /dev/null
+++ b/oauth_new/src/config/env.js
@@ -0,0 +1,17 @@
+// Environment configuration
+export const env = {
+ admin: import.meta.env.VITE_ADMIN,
+ pds: import.meta.env.VITE_PDS,
+ collection: import.meta.env.VITE_COLLECTION,
+ handleList: (() => {
+ try {
+ return JSON.parse(import.meta.env.VITE_HANDLE_LIST || '[]')
+ } catch {
+ return []
+ }
+ })(),
+ oauth: {
+ clientId: import.meta.env.VITE_OAUTH_CLIENT_ID,
+ redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI
+ }
+}
\ No newline at end of file
diff --git a/oauth_new/src/hooks/useAdminData.js b/oauth_new/src/hooks/useAdminData.js
new file mode 100644
index 0000000..63d5df3
--- /dev/null
+++ b/oauth_new/src/hooks/useAdminData.js
@@ -0,0 +1,57 @@
+import { useState, useEffect } from 'react'
+import { atproto, collections } from '../api/atproto.js'
+import { getApiConfig } from '../utils/pds.js'
+import { env } from '../config/env.js'
+
+export function useAdminData() {
+ const [adminData, setAdminData] = useState({
+ did: '',
+ profile: null,
+ records: [],
+ apiConfig: null
+ })
+ const [langRecords, setLangRecords] = useState([])
+ const [commentRecords, setCommentRecords] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ loadAdminData()
+ }, [])
+
+ const loadAdminData = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ const apiConfig = getApiConfig(`https://${env.pds}`)
+ const did = await atproto.getDid(env.pds, env.admin)
+ const profile = await atproto.getProfile(apiConfig.bsky, did)
+
+ // Load all data in parallel
+ const [records, lang, comment] = await Promise.all([
+ collections.getBase(apiConfig.pds, did, env.collection),
+ collections.getLang(apiConfig.pds, did, env.collection),
+ collections.getComment(apiConfig.pds, did, env.collection)
+ ])
+
+ setAdminData({ did, profile, records, apiConfig })
+ setLangRecords(lang)
+ setCommentRecords(comment)
+ } catch (err) {
+ console.error('Failed to load admin data:', err)
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return {
+ adminData,
+ langRecords,
+ commentRecords,
+ loading,
+ error,
+ refresh: loadAdminData
+ }
+}
\ No newline at end of file
diff --git a/oauth_new/src/hooks/useAuth.js b/oauth_new/src/hooks/useAuth.js
new file mode 100644
index 0000000..63a5b3c
--- /dev/null
+++ b/oauth_new/src/hooks/useAuth.js
@@ -0,0 +1,47 @@
+import { useState, useEffect } from 'react'
+import { OAuthService } from '../services/oauth.js'
+
+const oauthService = new OAuthService()
+
+export function useAuth() {
+ const [user, setUser] = useState(null)
+ const [agent, setAgent] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ initAuth()
+ }, [])
+
+ const initAuth = async () => {
+ try {
+ const authResult = await oauthService.checkAuth()
+ if (authResult) {
+ setUser(authResult.user)
+ setAgent(authResult.agent)
+ }
+ } catch (error) {
+ console.error('Auth initialization failed:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const login = async (handle) => {
+ await oauthService.login(handle)
+ }
+
+ const logout = async () => {
+ await oauthService.logout()
+ setUser(null)
+ setAgent(null)
+ }
+
+ return {
+ user,
+ agent,
+ loading,
+ login,
+ logout,
+ isAuthenticated: !!user
+ }
+}
\ No newline at end of file
diff --git a/oauth_new/src/hooks/usePageContext.js b/oauth_new/src/hooks/usePageContext.js
new file mode 100644
index 0000000..4ca82de
--- /dev/null
+++ b/oauth_new/src/hooks/usePageContext.js
@@ -0,0 +1,33 @@
+import { useState, useEffect } from 'react'
+
+export function usePageContext() {
+ const [pageContext, setPageContext] = useState({
+ isTopPage: true,
+ rkey: null,
+ url: null
+ })
+
+ useEffect(() => {
+ const pathname = window.location.pathname
+ const url = window.location.href
+
+ // Extract rkey from URL pattern: /posts/xxx or /posts/xxx.html
+ const match = pathname.match(/\/posts\/([^/]+)\/?$/)
+ if (match) {
+ const rkey = match[1].replace(/\.html$/, '')
+ setPageContext({
+ isTopPage: false,
+ rkey,
+ url
+ })
+ } else {
+ setPageContext({
+ isTopPage: true,
+ rkey: null,
+ url
+ })
+ }
+ }, [])
+
+ return pageContext
+}
\ No newline at end of file
diff --git a/oauth_new/src/hooks/useUserData.js b/oauth_new/src/hooks/useUserData.js
new file mode 100644
index 0000000..6de7a6f
--- /dev/null
+++ b/oauth_new/src/hooks/useUserData.js
@@ -0,0 +1,164 @@
+import { useState, useEffect } from 'react'
+import { atproto, collections } from '../api/atproto.js'
+import { getApiConfig, isSyuIsHandle } from '../utils/pds.js'
+import { env } from '../config/env.js'
+
+export function useUserData(adminData) {
+ const [userComments, setUserComments] = useState([])
+ const [chatRecords, setChatRecords] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!adminData?.did || !adminData?.apiConfig) return
+
+ const fetchUserData = async () => {
+ setLoading(true)
+ setError(null)
+
+ try {
+ // 1. Get user list from admin account
+ const userListRecords = await collections.getUserList(
+ adminData.apiConfig.pds,
+ adminData.did,
+ env.collection
+ )
+
+ // 2. Get chat records (ai.syui.log.chat doesn't exist, so skip for now)
+ setChatRecords([])
+
+ // 3. Get base collection records which contain user comments
+ const baseRecords = await collections.getBase(
+ adminData.apiConfig.pds,
+ adminData.did,
+ env.collection
+ )
+
+ // Extract comments from base records
+ const allUserComments = []
+
+ for (const record of baseRecords) {
+ if (record.value?.comments && Array.isArray(record.value.comments)) {
+ // Each comment already has author info, so we can use it directly
+ const commentsWithMeta = record.value.comments.map(comment => ({
+ uri: record.uri,
+ cid: record.cid,
+ value: {
+ ...comment,
+ post: {
+ url: record.value.url
+ }
+ }
+ }))
+ allUserComments.push(...commentsWithMeta)
+ }
+ }
+
+ // Also try to get individual user records from the user list
+ // Currently skipping user list processing since users contain placeholder DIDs
+ if (userListRecords.length > 0 && userListRecords[0].value?.users) {
+ console.log('User list found, but skipping placeholder users for now')
+
+ // Filter out placeholder users
+ const realUsers = userListRecords[0].value.users.filter(user =>
+ user.handle &&
+ user.did &&
+ !user.did.includes('placeholder') &&
+ !user.did.includes('example')
+ )
+
+ if (realUsers.length > 0) {
+ console.log(`Processing ${realUsers.length} real users`)
+
+ for (const user of realUsers) {
+ const userHandle = user.handle
+
+ try {
+ // Get user's DID and PDS using PDS detection logic
+ let userDid, userPds, userApiConfig
+
+ if (user.did && user.pds) {
+ // Use DID and PDS from user record
+ userDid = user.did
+ userPds = user.pds.replace('https://', '')
+ userApiConfig = getApiConfig(userPds)
+ } else {
+ // Auto-detect PDS based on handle and get real DID
+ if (isSyuIsHandle(userHandle)) {
+ userPds = env.pds
+ userApiConfig = getApiConfig(userPds)
+ userDid = await atproto.getDid(userPds, userHandle)
+ } else {
+ userPds = 'bsky.social'
+ userApiConfig = getApiConfig(userPds)
+ userDid = await atproto.getDid(userPds, userHandle)
+ }
+ }
+
+ // Get user's own ai.syui.log records
+ const userRecords = await collections.getUserComments(
+ userApiConfig.pds,
+ userDid,
+ env.collection
+ )
+
+ // Skip if no records found
+ if (!userRecords || userRecords.length === 0) {
+ continue
+ }
+
+ // Get user's profile for enrichment
+ let profile = null
+ try {
+ profile = await atproto.getProfile(userApiConfig.bsky, userDid)
+ } catch (profileError) {
+ console.warn(`Failed to get profile for ${userHandle}:`, profileError)
+ }
+
+ // Add profile info to each record
+ const enrichedRecords = userRecords.map(record => ({
+ ...record,
+ value: {
+ ...record.value,
+ author: {
+ did: userDid,
+ handle: profile?.data?.handle || userHandle,
+ displayName: profile?.data?.displayName || userHandle,
+ avatar: profile?.data?.avatar || null
+ }
+ }
+ }))
+
+ allUserComments.push(...enrichedRecords)
+ } catch (userError) {
+ console.warn(`Failed to fetch data for user ${userHandle}:`, userError)
+ }
+ }
+ } else {
+ console.log('No real users found in user list - all appear to be placeholders')
+ }
+ }
+
+ setUserComments(allUserComments)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchUserData()
+ }, [adminData])
+
+ const refresh = () => {
+ if (adminData?.did && adminData?.apiConfig) {
+ // Re-trigger the effect by clearing and re-setting adminData
+ const currentAdminData = adminData
+ setUserComments([])
+ setChatRecords([])
+ // The useEffect will automatically run again
+ }
+ }
+
+ return { userComments, chatRecords, loading, error, refresh }
+}
\ No newline at end of file
diff --git a/oauth_new/src/main.jsx b/oauth_new/src/main.jsx
new file mode 100644
index 0000000..c58ab71
--- /dev/null
+++ b/oauth_new/src/main.jsx
@@ -0,0 +1,5 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+
+ReactDOM.createRoot(document.getElementById('comment-atproto')).render()
\ No newline at end of file
diff --git a/oauth_new/src/services/oauth.js b/oauth_new/src/services/oauth.js
new file mode 100644
index 0000000..51aa7ce
--- /dev/null
+++ b/oauth_new/src/services/oauth.js
@@ -0,0 +1,144 @@
+import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
+import { Agent } from '@atproto/api'
+import { env } from '../config/env.js'
+import { isSyuIsHandle } from '../utils/pds.js'
+
+export class OAuthService {
+ constructor() {
+ this.clientId = env.oauth.clientId || this.getClientId()
+ this.clients = { bsky: null, syu: null }
+ this.agent = null
+ this.sessionInfo = null
+ this.initPromise = null
+ }
+
+ getClientId() {
+ const origin = window.location.origin
+ return origin.includes('localhost') || origin.includes('127.0.0.1')
+ ? undefined // Loopback client
+ : `${origin}/client-metadata.json`
+ }
+
+ async initialize() {
+ if (this.initPromise) return this.initPromise
+
+ this.initPromise = this._initialize()
+ return this.initPromise
+ }
+
+ async _initialize() {
+ try {
+ // Initialize OAuth clients
+ this.clients.bsky = await BrowserOAuthClient.load({
+ clientId: this.clientId,
+ handleResolver: 'https://bsky.social',
+ plcDirectoryUrl: 'https://plc.directory',
+ })
+
+ this.clients.syu = await BrowserOAuthClient.load({
+ clientId: this.clientId,
+ handleResolver: 'https://syu.is',
+ plcDirectoryUrl: 'https://plc.syu.is',
+ })
+
+ // Try to restore session
+ return await this.restoreSession()
+ } catch (error) {
+ console.error('OAuth initialization failed:', error)
+ this.initPromise = null
+ throw error
+ }
+ }
+
+ async restoreSession() {
+ // Try both clients
+ for (const client of [this.clients.bsky, this.clients.syu]) {
+ const result = await client.init()
+ if (result?.session) {
+ this.agent = new Agent(result.session)
+ return this.processSession(result.session)
+ }
+ }
+ return null
+ }
+
+ async processSession(session) {
+ const did = session.sub || session.did
+ let handle = session.handle || 'unknown'
+
+ this.sessionInfo = { did, handle }
+
+ // Resolve handle if missing
+ if (handle === 'unknown' && this.agent) {
+ try {
+ const profile = await this.agent.getProfile({ actor: did })
+ handle = profile.data.handle
+ this.sessionInfo.handle = handle
+ } catch (error) {
+ console.log('Failed to resolve handle:', error)
+ }
+ }
+
+ return { did, handle }
+ }
+
+ async login(handle) {
+ await this.initialize()
+
+ const client = isSyuIsHandle(handle) ? this.clients.syu : this.clients.bsky
+ const authUrl = await client.authorize(handle, { scope: 'atproto' })
+
+ window.location.href = authUrl.toString()
+ }
+
+ async checkAuth() {
+ try {
+ await this.initialize()
+ if (this.sessionInfo) {
+ return {
+ user: this.sessionInfo,
+ agent: this.agent
+ }
+ }
+ return null
+ } catch (error) {
+ console.error('Auth check failed:', error)
+ return null
+ }
+ }
+
+ async logout() {
+ try {
+ // Sign out from session
+ if (this.clients.bsky) {
+ const result = await this.clients.bsky.init()
+ if (result?.session?.signOut) {
+ await result.session.signOut()
+ }
+ }
+
+ // Clear state
+ this.agent = null
+ this.sessionInfo = null
+ this.clients = { bsky: null, syu: null }
+ this.initPromise = null
+
+ // Clear storage
+ localStorage.clear()
+ sessionStorage.clear()
+
+ // Reload page
+ window.location.reload()
+ } catch (error) {
+ console.error('Logout failed:', error)
+ }
+ }
+
+ getAgent() {
+ return this.agent
+ }
+
+ getUser() {
+ return this.sessionInfo
+ }
+}
\ No newline at end of file
diff --git a/oauth_new/src/utils/pds.js b/oauth_new/src/utils/pds.js
new file mode 100644
index 0000000..da23aa4
--- /dev/null
+++ b/oauth_new/src/utils/pds.js
@@ -0,0 +1,36 @@
+import { env } from '../config/env.js'
+
+// PDS判定からAPI設定を取得
+export function getApiConfig(pds) {
+ if (pds.includes(env.pds)) {
+ return {
+ pds: `https://${env.pds}`,
+ bsky: `https://bsky.${env.pds}`,
+ plc: `https://plc.${env.pds}`,
+ web: `https://web.${env.pds}`
+ }
+ }
+ return {
+ pds: pds.startsWith('http') ? pds : `https://${pds}`,
+ bsky: 'https://public.api.bsky.app',
+ plc: 'https://plc.directory',
+ web: 'https://bsky.app'
+ }
+}
+
+// handleがsyu.is系かどうか判定
+export function isSyuIsHandle(handle) {
+ return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
+}
+
+// handleからPDS取得
+export async function getPdsFromHandle(handle) {
+ const initialPds = isSyuIsHandle(handle)
+ ? `https://${env.pds}`
+ : 'https://bsky.social'
+
+ const data = await fetch(`${initialPds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`)
+ .then(res => res.json())
+
+ return data.didDoc?.service?.[0]?.serviceEndpoint || initialPds
+}
\ No newline at end of file
diff --git a/oauth_new/vite.config.js b/oauth_new/vite.config.js
new file mode 100644
index 0000000..d398460
--- /dev/null
+++ b/oauth_new/vite.config.js
@@ -0,0 +1,15 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ rollupOptions: {
+ output: {
+ entryFileNames: 'assets/comment-atproto-[hash].js',
+ chunkFileNames: 'assets/comment-atproto-[hash].js',
+ assetFileNames: 'assets/comment-atproto-[hash].[ext]'
+ }
+ }
+ }
+})
\ No newline at end of file