Compare commits
1 Commits
test-oauth
...
833549756b
Author | SHA1 | Date | |
---|---|---|---|
833549756b
|
@@ -124,17 +124,8 @@ function App() {
|
|||||||
loadAIGeneratedContent();
|
loadAIGeneratedContent();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wait for DID resolution before loading data
|
// Load data immediately with fallback DIDs (skip DID resolution wait)
|
||||||
if (adminDid && aiDid) {
|
loadDataAfterDidResolution();
|
||||||
loadDataAfterDidResolution();
|
|
||||||
} else {
|
|
||||||
// Wait a bit and try again
|
|
||||||
setTimeout(() => {
|
|
||||||
if (adminDid && aiDid) {
|
|
||||||
loadDataAfterDidResolution();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load AI profile from handle
|
// Load AI profile from handle
|
||||||
const loadAiProfile = async () => {
|
const loadAiProfile = async () => {
|
||||||
@@ -332,8 +323,8 @@ function App() {
|
|||||||
// Load all chat records from users in admin's user list
|
// Load all chat records from users in admin's user list
|
||||||
const currentAdminDid = adminDid || appConfig.adminDid;
|
const currentAdminDid = adminDid || appConfig.adminDid;
|
||||||
|
|
||||||
// Don't proceed if we don't have a valid DID
|
// Use fallback DID if resolution failed
|
||||||
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
|
if (!currentAdminDid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,8 +442,8 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const currentAdminDid = adminDid || appConfig.adminDid;
|
const currentAdminDid = adminDid || appConfig.adminDid;
|
||||||
|
|
||||||
// Don't proceed if we don't have a valid DID
|
// Use fallback DID if resolution failed
|
||||||
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
|
if (!currentAdminDid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,6 +0,0 @@
|
|||||||
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
|
|
@@ -1,334 +0,0 @@
|
|||||||
# 開発ガイド
|
|
||||||
|
|
||||||
## 設計思想
|
|
||||||
|
|
||||||
このプロジェクトは以下の原則に基づいて設計されています:
|
|
||||||
|
|
||||||
### 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 <div>{data.map(...)}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Good: Hooksでロジック分離
|
|
||||||
function MyComponent() {
|
|
||||||
const { data, loading, error } = useMyData()
|
|
||||||
if (loading) return <Loading />
|
|
||||||
if (error) return <Error />
|
|
||||||
return <div>{data.map(...)}</div>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## デバッグ手法
|
|
||||||
|
|
||||||
### 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])
|
|
||||||
}
|
|
||||||
```
|
|
@@ -1,222 +0,0 @@
|
|||||||
# 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)
|
|
@@ -1,11 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Comments Test</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="comment-atproto"></div>
|
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,76 +0,0 @@
|
|||||||
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 <OAuthCallback />
|
|
||||||
}
|
|
||||||
|
|
||||||
const isLoading = authLoading || dataLoading || userLoading
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
|
||||||
<h1>ATProto OAuth Demo</h1>
|
|
||||||
<p>読み込み中...</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
|
||||||
<h1>ATProto OAuth Demo</h1>
|
|
||||||
<p style={{ color: 'red' }}>エラー: {error}</p>
|
|
||||||
<button onClick={() => window.location.reload()}>
|
|
||||||
再読み込み
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
|
||||||
<header style={{ marginBottom: '20px' }}>
|
|
||||||
<h1>ATProto OAuth Demo</h1>
|
|
||||||
<AuthButton
|
|
||||||
user={user}
|
|
||||||
onLogin={login}
|
|
||||||
onLogout={logout}
|
|
||||||
loading={authLoading}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<CommentForm
|
|
||||||
user={user}
|
|
||||||
agent={agent}
|
|
||||||
onCommentPosted={() => {
|
|
||||||
refreshAdminData?.()
|
|
||||||
refreshUserData?.()
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RecordTabs
|
|
||||||
langRecords={langRecords}
|
|
||||||
commentRecords={commentRecords}
|
|
||||||
userComments={userComments}
|
|
||||||
chatRecords={chatRecords}
|
|
||||||
apiConfig={adminData.apiConfig}
|
|
||||||
pageContext={pageContext}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,80 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,102 +0,0 @@
|
|||||||
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 <div>認証状態を確認中...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
return (
|
|
||||||
<div className="auth-status">
|
|
||||||
<div>ログイン中: <strong>{user.handle}</strong></div>
|
|
||||||
<button onClick={onLogout} className="logout-btn">
|
|
||||||
ログアウト
|
|
||||||
</button>
|
|
||||||
<style jsx>{`
|
|
||||||
.auth-status {
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
.logout-btn {
|
|
||||||
margin-top: 5px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="auth-form">
|
|
||||||
<h3>OAuth認証</h3>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={handleInput}
|
|
||||||
onChange={(e) => setHandleInput(e.target.value)}
|
|
||||||
placeholder="Handle (e.g. your.handle.com)"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="handle-input"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || !handleInput.trim()}
|
|
||||||
className="login-btn"
|
|
||||||
>
|
|
||||||
{isLoading ? 'ログイン中...' : 'ログイン'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<style jsx>{`
|
|
||||||
.auth-form {
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
.handle-input {
|
|
||||||
width: 200px;
|
|
||||||
margin-right: 10px;
|
|
||||||
padding: 5px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.login-btn {
|
|
||||||
padding: 5px 10px;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.login-btn:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,200 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div className="comment-form-placeholder">
|
|
||||||
<p>ログインしてコメントを投稿</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="comment-form">
|
|
||||||
<h3>コメントを投稿</h3>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="comment-url">ページURL:</label>
|
|
||||||
<input
|
|
||||||
id="comment-url"
|
|
||||||
type="url"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
placeholder="https://syui.ai/posts/example"
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label htmlFor="comment-text">コメント:</label>
|
|
||||||
<textarea
|
|
||||||
id="comment-text"
|
|
||||||
value={text}
|
|
||||||
onChange={(e) => setText(e.target.value)}
|
|
||||||
placeholder="コメントを入力してください..."
|
|
||||||
rows={4}
|
|
||||||
required
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="error-message">
|
|
||||||
エラー: {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="form-actions">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !text.trim() || !url.trim()}
|
|
||||||
className="submit-btn"
|
|
||||||
>
|
|
||||||
{loading ? '投稿中...' : 'コメントを投稿'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.comment-form {
|
|
||||||
border: 2px solid #007bff;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
.comment-form-placeholder {
|
|
||||||
border: 2px dashed #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
margin: 20px 0;
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
.comment-form h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
.form-group label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.form-group input,
|
|
||||||
.form-group textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.form-group input:focus,
|
|
||||||
.form-group textarea:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
|
||||||
}
|
|
||||||
.form-group input:disabled,
|
|
||||||
.form-group textarea:disabled {
|
|
||||||
background: #e9ecef;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.error-message {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
.form-actions {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.submit-btn {
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
.submit-btn:hover:not(:disabled) {
|
|
||||||
background: #0056b3;
|
|
||||||
}
|
|
||||||
.submit-btn:disabled {
|
|
||||||
background: #6c757d;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,50 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
|
||||||
<h2>OAuth認証</h2>
|
|
||||||
<p>{status}</p>
|
|
||||||
{status.includes('エラー') && (
|
|
||||||
<button onClick={() => window.location.href = '/'}>
|
|
||||||
メインページに戻る
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,58 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
export default function RecordList({ title, records, apiConfig, showTitle = true }) {
|
|
||||||
if (!records || records.length === 0) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
{showTitle && <h3>{title} (0)</h3>}
|
|
||||||
<p>レコードがありません</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
{showTitle && <h3>{title} ({records.length})</h3>}
|
|
||||||
{records.map((record, i) => (
|
|
||||||
<div key={i} style={{ border: '1px solid #ddd', margin: '10px 0', padding: '10px' }}>
|
|
||||||
{record.value.author?.avatar && (
|
|
||||||
<img
|
|
||||||
src={record.value.author.avatar}
|
|
||||||
alt="avatar"
|
|
||||||
style={{ width: '32px', height: '32px', borderRadius: '50%', marginRight: '10px' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div><strong>{record.value.author?.displayName || record.value.author?.handle}</strong></div>
|
|
||||||
<div>
|
|
||||||
Handle:
|
|
||||||
<a
|
|
||||||
href={`${apiConfig?.web}/profile/${record.value.author?.did}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ marginLeft: '5px' }}
|
|
||||||
>
|
|
||||||
{record.value.author?.handle}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div style={{ margin: '10px 0' }}>{record.value.text || record.value.content}</div>
|
|
||||||
{record.value.post?.url && (
|
|
||||||
<div>
|
|
||||||
URL:
|
|
||||||
<a
|
|
||||||
href={record.value.post.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ marginLeft: '5px' }}
|
|
||||||
>
|
|
||||||
{record.value.post.url}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
|
|
||||||
{new Date(record.value.createdAt).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,151 +0,0 @@
|
|||||||
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 (
|
|
||||||
<div className="record-tabs">
|
|
||||||
<div className="tab-header">
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('lang')}
|
|
||||||
>
|
|
||||||
Lang Records ({filteredLangRecords.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('comment')}
|
|
||||||
>
|
|
||||||
Comment Records ({filteredCommentRecords.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('collection')}
|
|
||||||
>
|
|
||||||
Collection ({filteredChatRecords.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
|
||||||
onClick={() => setActiveTab('users')}
|
|
||||||
>
|
|
||||||
User Comments ({filteredUserComments.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="tab-content">
|
|
||||||
{activeTab === 'lang' && (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
|
|
||||||
records={filteredLangRecords}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === 'comment' && (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
|
|
||||||
records={filteredCommentRecords}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === 'collection' && (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
|
|
||||||
records={filteredChatRecords}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{activeTab === 'users' && (
|
|
||||||
<RecordList
|
|
||||||
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
|
|
||||||
records={filteredUserComments}
|
|
||||||
apiConfig={apiConfig}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="page-info">
|
|
||||||
<small>
|
|
||||||
{pageContext.isTopPage
|
|
||||||
? "トップページ: 最新3件を表示"
|
|
||||||
: `個別ページ: ${pageContext.rkey} に関連するレコードを表示`
|
|
||||||
}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.record-tabs {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.tab-header {
|
|
||||||
display: flex;
|
|
||||||
border-bottom: 2px solid #ddd;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.tab-btn {
|
|
||||||
padding: 10px 20px;
|
|
||||||
border: none;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-top: 2px solid transparent;
|
|
||||||
border-left: 1px solid #ddd;
|
|
||||||
border-right: 1px solid #ddd;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
.tab-btn:first-child {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
.tab-btn:last-child {
|
|
||||||
border-right: none;
|
|
||||||
}
|
|
||||||
.tab-btn.active {
|
|
||||||
background: white;
|
|
||||||
border-top-color: #007bff;
|
|
||||||
border-bottom: 2px solid white;
|
|
||||||
margin-bottom: -2px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.tab-btn:hover:not(.active) {
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
.tab-content {
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
.page-info {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 5px 10px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 3px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,115 +0,0 @@
|
|||||||
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 (
|
|
||||||
<section className="user-lookup">
|
|
||||||
<h3>ユーザー検索</h3>
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={handleInput}
|
|
||||||
onChange={(e) => setHandleInput(e.target.value)}
|
|
||||||
placeholder="Enter handle (e.g. syui.syui.ai)"
|
|
||||||
disabled={loading}
|
|
||||||
className="search-input"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !handleInput.trim()}
|
|
||||||
className="search-btn"
|
|
||||||
>
|
|
||||||
{loading ? '検索中...' : '検索'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{userInfo && (
|
|
||||||
<div className="user-result">
|
|
||||||
<h4>ユーザー情報:</h4>
|
|
||||||
{userInfo.error ? (
|
|
||||||
<div className="error">エラー: {userInfo.error}</div>
|
|
||||||
) : (
|
|
||||||
<div className="user-details">
|
|
||||||
<div>Handle: {userInfo.handle}</div>
|
|
||||||
<div>PDS: {userInfo.pds}</div>
|
|
||||||
<div>DID: {userInfo.did}</div>
|
|
||||||
<div>Display Name: {userInfo.profile?.displayName}</div>
|
|
||||||
<div>PDS API: {userInfo.config?.pds}</div>
|
|
||||||
<div>Bsky API: {userInfo.config?.bsky}</div>
|
|
||||||
<div>Web: {userInfo.config?.web}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<style jsx>{`
|
|
||||||
.user-lookup {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.search-input {
|
|
||||||
width: 200px;
|
|
||||||
margin-right: 10px;
|
|
||||||
padding: 5px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.search-btn {
|
|
||||||
padding: 5px 10px;
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.search-btn:disabled {
|
|
||||||
background: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
.user-result {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
.user-details div {
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
@@ -1,17 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,57 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,47 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,33 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@@ -1,164 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
@@ -1,5 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import App from './App'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('comment-atproto')).render(<App />)
|
|
@@ -1,144 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
@@ -1,15 +0,0 @@
|
|||||||
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]'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
Reference in New Issue
Block a user