Compare commits
18 Commits
5f0b09b555
...
v0.2.2
Author | SHA1 | Date | |
---|---|---|---|
b17ac3d91a
|
|||
81f87d0462
|
|||
a020fa24d8
|
|||
21c53010b7
|
|||
4f7834f85c
|
|||
fecd927b91
|
|||
b54e8089ea
|
|||
174cb12d4d
|
|||
a1186f8185
|
|||
833549756b
|
|||
4edde5293a
|
|||
f0fdf678c8
|
|||
820e47f634
|
|||
4dac4a83e0
|
|||
fccf75949c
|
|||
6600a9e0cf
|
|||
0d79af5aa5
|
|||
db04af76ab
|
@@ -51,7 +51,9 @@
|
||||
"Bash(ailog:*)",
|
||||
"WebFetch(domain:plc.directory)",
|
||||
"WebFetch(domain:atproto.com)",
|
||||
"WebFetch(domain:syu.is)"
|
||||
"WebFetch(domain:syu.is)",
|
||||
"Bash(sed:*)",
|
||||
"Bash(./scpt/run.zsh:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
64
.github/workflows/cloudflare-pages.yml
vendored
64
.github/workflows/cloudflare-pages.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
OAUTH_DIR: oauth_new
|
||||
KEEP_DEPLOYMENTS: 5
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -24,22 +28,18 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd oauth
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
npm install
|
||||
|
||||
- name: Build OAuth app
|
||||
run: |
|
||||
cd oauth
|
||||
npm run build
|
||||
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
NODE_ENV=production npm run build
|
||||
- name: Copy OAuth build to static
|
||||
run: |
|
||||
# Remove old assets (following run.zsh pattern)
|
||||
rm -rf my-blog/static/assets
|
||||
# Copy all dist files to static
|
||||
cp -rf oauth/dist/* my-blog/static/
|
||||
# Copy index.html to oauth-assets.html template
|
||||
cp oauth/dist/index.html my-blog/templates/oauth-assets.html
|
||||
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
|
||||
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
|
||||
|
||||
- name: Cache ailog binary
|
||||
uses: actions/cache@v4
|
||||
@@ -109,3 +109,49 @@ jobs:
|
||||
directory: my-blog/public
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
wranglerVersion: '3'
|
||||
|
||||
cleanup:
|
||||
needs: deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- name: Wait for deployment to complete
|
||||
run: sleep 3
|
||||
|
||||
- name: Cleanup old deployments
|
||||
run: |
|
||||
# Get all deployments
|
||||
DEPLOYMENTS=$(curl -s -X GET \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
# Extract deployment IDs (skip the latest N deployments)
|
||||
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
|
||||
|
||||
if [ -z "$DEPLOYMENT_IDS" ]; then
|
||||
echo "No old deployments to delete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Delete old deployments
|
||||
for ID in $DEPLOYMENT_IDS; do
|
||||
echo "Deleting deployment: $ID"
|
||||
RESPONSE=$(curl -s -X DELETE \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
||||
if [ "$SUCCESS" = "true" ]; then
|
||||
echo "Successfully deleted deployment: $ID"
|
||||
else
|
||||
echo "Failed to delete deployment: $ID"
|
||||
echo "$RESPONSE" | jq .
|
||||
fi
|
||||
|
||||
sleep 1 # Rate limiting
|
||||
done
|
||||
|
||||
echo "Cleanup completed!"
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ my-blog/templates/oauth-assets.html
|
||||
cloudflared-config.yml
|
||||
.config
|
||||
oauth-server-example
|
||||
atproto
|
||||
|
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ailog"
|
||||
version = "0.1.9"
|
||||
version = "0.2.2"
|
||||
edition = "2021"
|
||||
authors = ["syui"]
|
||||
description = "A static blog generator with AI features"
|
||||
@@ -10,6 +10,10 @@ license = "MIT"
|
||||
name = "ailog"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "ailog"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
pulldown-cmark = "0.11"
|
||||
|
Binary file not shown.
208
claude.md
208
claude.md
@@ -14,6 +14,214 @@ VITE_OAUTH_COLLECTION_USER=ai.syui.log.user
|
||||
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
|
||||
```
|
||||
|
||||
## oauth appの設計
|
||||
|
||||
> ./oauth/.env.production
|
||||
|
||||
```sh
|
||||
VITE_ATPROTO_PDS=syu.is
|
||||
VITE_ADMIN_HANDLE=ai.syui.ai
|
||||
VITE_AI_HANDLE=ai.syui.ai
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
```
|
||||
|
||||
これらは非常にシンプルな流れになっており、すべての項目は、共通します。短縮できる場合があります。handleは変わる可能性があるので、できる限りdidを使いましょう。
|
||||
|
||||
1. handleからpds, didを取得できる ... com.atproto.repo.describeRepo
|
||||
2. pdsが分かれば、pdsApi, bskyApi, plcApiを割り当てられる
|
||||
3. bskyApiが分かれば、getProfileでavatar-uriを取得できる ... app.bsky.actor.getProfile
|
||||
4. pdsAPiからアカウントにあるcollectionのrecordの情報を取得できる ... com.atproto.repo.listRecords
|
||||
|
||||
### コメントを表示する
|
||||
|
||||
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
|
||||
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
|
||||
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.user`というuserlistを取得する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.user"
|
||||
---
|
||||
syui.ai
|
||||
```
|
||||
|
||||
5. ユーザーがわかったら、そのユーザーのpdsを判定する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".didDoc.service.[].serviceEndpoint"
|
||||
---
|
||||
https://shiitake.us-east.host.bsky.network
|
||||
|
||||
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".did"
|
||||
---
|
||||
did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
```
|
||||
|
||||
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. ユーザーの情報を取得、表示する
|
||||
|
||||
```sh
|
||||
bsky_api=https://public.api.bsky.app
|
||||
user_did=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
|
||||
---
|
||||
https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg
|
||||
```
|
||||
|
||||
### AIの情報を表示する
|
||||
|
||||
AIが持つ`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を表示します。
|
||||
|
||||
なお、これは通常、`VITE_ADMIN_HANDLE`にputRecordされます。そこから情報を読み込みます。`VITE_AI_HANDLE`はそのrecordの`author`のところに入ります。
|
||||
|
||||
```json
|
||||
"author": {
|
||||
"did": "did:plc:4hqjfn7m6n5hno3doamuhgef",
|
||||
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg",
|
||||
"handle": "yui.syui.ai",
|
||||
"displayName": "ai"
|
||||
}
|
||||
```
|
||||
|
||||
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
|
||||
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
|
||||
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を取得する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.chat.comment"
|
||||
```
|
||||
|
||||
5. AIのprofileを取得する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".didDoc.service.[].serviceEndpoint"
|
||||
---
|
||||
https://syu.is
|
||||
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".did"
|
||||
did:plc:6qyecktefllvenje24fcxnie
|
||||
```
|
||||
|
||||
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. AIの情報を取得、表示する
|
||||
|
||||
```sh
|
||||
bsky_api=https://bsky.syu.is
|
||||
user_did=did:plc:6qyecktefllvenje24fcxnie
|
||||
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
|
||||
---
|
||||
https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg
|
||||
```
|
||||
|
||||
## 中核思想
|
||||
- **存在子理論**: この世界で最も小さいもの(存在子/ai)の探求
|
||||
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
|
||||
|
@@ -16,8 +16,10 @@ auto_translate = false
|
||||
comment_moderation = false
|
||||
ask_ai = true
|
||||
provider = "ollama"
|
||||
model = "gemma3:4b"
|
||||
host = "https://ollama.syui.ai"
|
||||
model = "qwen3"
|
||||
model_translation = "llama3.2:1b"
|
||||
model_technical = "phi3:mini"
|
||||
host = "http://localhost:11434"
|
||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
handle = "ai.syui.ai"
|
||||
#num_predict = 200
|
||||
@@ -28,4 +30,4 @@ redirect = "oauth/callback"
|
||||
admin = "ai.syui.ai"
|
||||
collection = "ai.syui.log"
|
||||
pds = "syu.is"
|
||||
handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"]
|
||||
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]
|
||||
|
@@ -9,12 +9,12 @@ VITE_ADMIN_HANDLE=ai.syui.ai
|
||||
VITE_AI_HANDLE=ai.syui.ai
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
VITE_ATPROTO_WEB_URL=https://bsky.app
|
||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"]
|
||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
|
||||
|
||||
# AI Configuration
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:4b
|
||||
VITE_AI_HOST=https://ollama.syui.ai
|
||||
VITE_AI_MODEL=qwen3
|
||||
VITE_AI_HOST=http://localhost:11434
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"client_id": "https://syui.ai/client-metadata.json",
|
||||
"client_name": "ai.card",
|
||||
"client_name": "ai.log",
|
||||
"client_uri": "https://syui.ai",
|
||||
"logo_uri": "https://syui.ai/favicon.ico",
|
||||
"tos_uri": "https://syui.ai/terms",
|
||||
|
@@ -9,13 +9,13 @@ VITE_ADMIN_HANDLE=ai.syui.ai
|
||||
VITE_AI_HANDLE=ai.syui.ai
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
VITE_ATPROTO_WEB_URL=https://bsky.app
|
||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","yui.syui.ai","syui.syu.is","ai.syu.is"]
|
||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"]
|
||||
|
||||
# AI Configuration
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:4b
|
||||
VITE_AI_MODEL=gemma3:1b
|
||||
VITE_AI_HOST=https://ollama.syui.ai
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
|
||||
|
@@ -7,7 +7,9 @@
|
||||
"build": "vite build --mode production",
|
||||
"build:dev": "vite build --mode development",
|
||||
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
|
||||
"preview": "vite preview"
|
||||
"preview": "npm run test:console && vite preview",
|
||||
"test": "vitest",
|
||||
"test:console": "node -r esbuild-register src/tests/console-test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.12",
|
||||
@@ -26,6 +28,9 @@
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
"vite": "^5.0.10",
|
||||
"vitest": "^1.1.0",
|
||||
"esbuild": "^0.19.10",
|
||||
"esbuild-register": "^3.5.0"
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"client_id": "https://log.syui.ai/client-metadata.json",
|
||||
"client_name": "ai.card",
|
||||
"client_uri": "https://log.syui.ai",
|
||||
"logo_uri": "https://log.syui.ai/favicon.ico",
|
||||
"tos_uri": "https://log.syui.ai/terms",
|
||||
"policy_uri": "https://log.syui.ai/privacy",
|
||||
"client_id": "https://syui.ai/client-metadata.json",
|
||||
"client_name": "ai.log",
|
||||
"client_uri": "https://syui.ai",
|
||||
"logo_uri": "https://syui.ai/favicon.ico",
|
||||
"tos_uri": "https://syui.ai/terms",
|
||||
"policy_uri": "https://syui.ai/privacy",
|
||||
"redirect_uris": [
|
||||
"https://log.syui.ai/oauth/callback",
|
||||
"https://log.syui.ai/"
|
||||
"https://syui.ai/oauth/callback",
|
||||
"https://syui.ai/"
|
||||
],
|
||||
"response_types": [
|
||||
"code"
|
||||
|
@@ -5,6 +5,7 @@ import { authService, User } from './services/auth';
|
||||
import { atprotoOAuthService } from './services/atproto-oauth';
|
||||
import { appConfig, getCollectionNames } from './config/app';
|
||||
import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection';
|
||||
import { isValidDid } from './utils/validation';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -118,19 +119,13 @@ function App() {
|
||||
|
||||
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
|
||||
loadAiChatHistory();
|
||||
|
||||
// Load AI generated content (lang:en and AI comments)
|
||||
loadAIGeneratedContent();
|
||||
};
|
||||
|
||||
// Wait for DID resolution before loading data
|
||||
if (adminDid && aiDid) {
|
||||
// Load data immediately with fallback DIDs (skip DID resolution wait)
|
||||
loadDataAfterDidResolution();
|
||||
} else {
|
||||
// Wait a bit and try again
|
||||
setTimeout(() => {
|
||||
if (adminDid && aiDid) {
|
||||
loadDataAfterDidResolution();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Load AI profile from handle
|
||||
const loadAiProfile = async () => {
|
||||
@@ -154,6 +149,7 @@ function App() {
|
||||
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
||||
const apiEndpoint = config.bskyApi;
|
||||
|
||||
|
||||
// Get profile from appropriate bsky API
|
||||
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||||
if (profileResponse.ok) {
|
||||
@@ -219,13 +215,7 @@ function App() {
|
||||
// Ensure handle is not DID
|
||||
const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
|
||||
|
||||
// Check if handle is allowed
|
||||
if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(handle)) {
|
||||
// Handle not in allowed list
|
||||
setError(`Access denied: ${handle} is not authorized for this application.`);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Note: appConfig.allowedHandles is used for PDS detection, not access control
|
||||
|
||||
// Get user profile including avatar
|
||||
const userProfile = await getUserProfile(oauthResult.did, handle);
|
||||
@@ -290,6 +280,7 @@ function App() {
|
||||
if (adminDid && aiDid) {
|
||||
loadAllComments();
|
||||
loadAiChatHistory();
|
||||
loadAIGeneratedContent();
|
||||
}
|
||||
}, [adminDid, aiDid]);
|
||||
|
||||
@@ -331,19 +322,26 @@ function App() {
|
||||
try {
|
||||
// Load all chat records from users in admin's user list
|
||||
const currentAdminDid = adminDid || appConfig.adminDid;
|
||||
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
|
||||
const collections = getCollectionNames(appConfig.collections.base);
|
||||
|
||||
// First, get user list from admin using their proper PDS
|
||||
// Use fallback DID if resolution failed
|
||||
if (!currentAdminDid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve admin's actual PDS from their DID
|
||||
let adminPdsEndpoint;
|
||||
try {
|
||||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||||
adminPdsEndpoint = config.pdsApi;
|
||||
} catch {
|
||||
adminPdsEndpoint = atprotoApi;
|
||||
// Fallback to configured PDS
|
||||
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
|
||||
adminPdsEndpoint = adminConfig.pdsApi;
|
||||
}
|
||||
|
||||
const collections = getCollectionNames(appConfig.collections.base);
|
||||
|
||||
const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
|
||||
|
||||
if (!userListResponse.ok) {
|
||||
@@ -378,6 +376,10 @@ function App() {
|
||||
// Use per-user PDS detection for each user's chat records
|
||||
let userPdsEndpoint;
|
||||
try {
|
||||
// Validate DID format before making API calls
|
||||
if (!userDid || !userDid.startsWith('did:')) {
|
||||
continue;
|
||||
}
|
||||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid));
|
||||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||||
userPdsEndpoint = config.pdsApi;
|
||||
@@ -391,6 +393,9 @@ function App() {
|
||||
const chatData = await chatResponse.json();
|
||||
const records = chatData.records || [];
|
||||
allChatRecords.push(...records);
|
||||
} else if (chatResponse.status === 400) {
|
||||
// Skip 400 errors (repo not found, etc)
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
continue;
|
||||
@@ -436,11 +441,27 @@ function App() {
|
||||
const loadAIGeneratedContent = async () => {
|
||||
try {
|
||||
const currentAdminDid = adminDid || appConfig.adminDid;
|
||||
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
|
||||
|
||||
// Use fallback DID if resolution failed
|
||||
if (!currentAdminDid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve admin's actual PDS from their DID
|
||||
let atprotoApi;
|
||||
try {
|
||||
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
|
||||
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
|
||||
atprotoApi = config.pdsApi;
|
||||
} catch {
|
||||
// Fallback to configured PDS
|
||||
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
|
||||
atprotoApi = adminConfig.pdsApi;
|
||||
}
|
||||
const collections = getCollectionNames(appConfig.collections.base);
|
||||
|
||||
// Load lang:en records
|
||||
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
|
||||
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
|
||||
if (langResponse.ok) {
|
||||
const langData = await langResponse.json();
|
||||
const langRecords = langData.records || [];
|
||||
@@ -457,8 +478,14 @@ function App() {
|
||||
setLangEnRecords(filteredLangRecords);
|
||||
}
|
||||
|
||||
// Load AI comment records
|
||||
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
|
||||
// Load AI comment records from admin account (not AI account)
|
||||
if (!currentAdminDid) {
|
||||
console.warn('No Admin DID available, skipping AI comment loading');
|
||||
setAiCommentRecords([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
|
||||
if (commentResponse.ok) {
|
||||
const commentData = await commentResponse.json();
|
||||
const commentRecords = commentData.records || [];
|
||||
@@ -508,6 +535,13 @@ function App() {
|
||||
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||||
if (profileResponse.ok) {
|
||||
const profileData = await profileResponse.json();
|
||||
|
||||
// Determine correct web URL based on avatar source
|
||||
let webUrl = config.webUrl; // Default from handle-based detection
|
||||
if (profileData.avatar && profileData.avatar.includes('cdn.bsky.app')) {
|
||||
webUrl = 'https://bsky.app'; // Override to Bluesky if avatar is from Bluesky
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
value: {
|
||||
@@ -517,7 +551,7 @@ function App() {
|
||||
avatar: profileData.avatar,
|
||||
displayName: profileData.displayName || handle,
|
||||
_pdsEndpoint: `https://${pds}`, // Store PDS info for later use
|
||||
_webUrl: config.webUrl, // Store web URL for profile links
|
||||
_webUrl: webUrl, // Store corrected web URL for profile links
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -768,6 +802,14 @@ function App() {
|
||||
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(record.value.author.handle));
|
||||
|
||||
if (profile) {
|
||||
// Determine network config based on profile data
|
||||
let webUrl = 'https://bsky.app'; // Default to Bluesky
|
||||
if (profile.avatar && profile.avatar.includes('cdn.bsky.app')) {
|
||||
webUrl = 'https://bsky.app';
|
||||
} else if (profile.avatar && profile.avatar.includes('bsky.syu.is')) {
|
||||
webUrl = 'https://web.syu.is';
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
value: {
|
||||
@@ -776,6 +818,7 @@ function App() {
|
||||
...record.value.author,
|
||||
avatar: profile.avatar,
|
||||
displayName: profile.displayName || record.value.author.handle,
|
||||
_webUrl: webUrl, // Store network config for profile URL generation
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1092,14 +1135,34 @@ function App() {
|
||||
|
||||
// ユーザーハンドルからプロフィールURLを生成
|
||||
const generateProfileUrl = (author: any): string => {
|
||||
// Use stored PDS info if available (from comment enhancement)
|
||||
// Check if this is admin/AI handle that should use configured PDS
|
||||
if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) {
|
||||
const config = getNetworkConfig(appConfig.atprotoPds);
|
||||
return `${config.webUrl}/profile/${author.did}`;
|
||||
}
|
||||
|
||||
// For ai.syu.is handle, also use configured PDS
|
||||
if (author.handle === 'ai.syu.is') {
|
||||
const config = getNetworkConfig(appConfig.atprotoPds);
|
||||
return `${config.webUrl}/profile/${author.did}`;
|
||||
}
|
||||
|
||||
// For other users, detect network based on avatar URL or stored network info
|
||||
if (author.avatar && author.avatar.includes('cdn.bsky.app')) {
|
||||
// User has Bluesky avatar, use Bluesky web interface
|
||||
return `https://bsky.app/profile/${author.did}`;
|
||||
}
|
||||
|
||||
// Check if we have stored network config from profile fetching
|
||||
if (author._webUrl) {
|
||||
return `${author._webUrl}/profile/${author.did}`;
|
||||
}
|
||||
|
||||
// Fallback to handle-based detection
|
||||
// Fallback: Get PDS from handle for other users
|
||||
const pds = detectPdsFromHandle(author.handle);
|
||||
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
||||
const config = getNetworkConfig(pds);
|
||||
|
||||
// Use DID for profile URL
|
||||
return `${config.webUrl}/profile/${author.did}`;
|
||||
};
|
||||
|
||||
@@ -1135,7 +1198,9 @@ function App() {
|
||||
|
||||
// Extract content based on format
|
||||
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
|
||||
const authorInfo = isNewFormat ? value.author : null;
|
||||
// Use the author from the record if available, otherwise fall back to AI profile
|
||||
const authorInfo = value.author || aiProfile;
|
||||
|
||||
const postInfo = isNewFormat ? value.post : null;
|
||||
const contentType = value.type || 'unknown';
|
||||
const createdAt = value.createdAt || value.generated_at || '';
|
||||
@@ -1147,29 +1212,22 @@ function App() {
|
||||
src={authorInfo?.avatar || generatePlaceholderAvatar('AI')}
|
||||
alt="AI Avatar"
|
||||
className="comment-avatar"
|
||||
ref={(img) => {
|
||||
// For old format, try to fetch from ai_did
|
||||
if (img && !isNewFormat && value.ai_did) {
|
||||
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(value.ai_did)}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.avatar && img) {
|
||||
img.src = data.avatar;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
// Keep placeholder on error
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="comment-author-info">
|
||||
<span className="comment-author">
|
||||
{authorInfo?.displayName || 'AI'}
|
||||
</span>
|
||||
<span className="comment-handle">
|
||||
@{authorInfo?.handle || aiProfile?.handle || 'yui.syui.ai'}
|
||||
{authorInfo?.displayName || 'ai'}
|
||||
</span>
|
||||
<a
|
||||
href={generateProfileUrl({
|
||||
handle: authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle,
|
||||
did: authorInfo?.did || aiProfile?.did || appConfig.aiDid
|
||||
})}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="comment-handle"
|
||||
>
|
||||
@{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle}
|
||||
</a>
|
||||
</div>
|
||||
<span className="comment-date">
|
||||
{new Date(createdAt).toLocaleString()}
|
||||
@@ -1296,7 +1354,7 @@ function App() {
|
||||
name="userList"
|
||||
value={userListInput}
|
||||
onChange={(e) => setUserListInput(e.target.value)}
|
||||
placeholder="ユーザーハンドルをカンマ区切りで入力 例: syui.ai, yui.syui.ai, user.bsky.social"
|
||||
placeholder="ユーザーハンドルをカンマ区切りで入力 例: syui.ai, ai.syui.ai, user.bsky.social"
|
||||
rows={3}
|
||||
disabled={isPostingUserList}
|
||||
/>
|
||||
@@ -1493,93 +1551,9 @@ function App() {
|
||||
{aiChatHistory.length === 0 ? (
|
||||
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
|
||||
) : (
|
||||
aiChatHistory.map((record, index) => {
|
||||
// For AI responses, use AI DID; for user questions, use the actual author
|
||||
const isAiResponse = record.value.type === 'answer';
|
||||
const displayDid = isAiResponse ? (aiDid || appConfig.aiDid) : record.value.author?.did;
|
||||
const displayHandle = isAiResponse ? (aiProfile?.handle || 'yui.syui.ai') : record.value.author?.handle;
|
||||
const displayName = isAiResponse ? 'AI' : (record.value.author?.displayName || record.value.author?.handle);
|
||||
|
||||
return (
|
||||
<div key={index} className="comment-item">
|
||||
<div className="comment-header">
|
||||
<img
|
||||
src={generatePlaceholderAvatar(displayHandle || 'unknown')}
|
||||
alt={isAiResponse ? "AI Avatar" : "User Avatar"}
|
||||
className="comment-avatar"
|
||||
ref={(img) => {
|
||||
// Fetch fresh avatar from API when component mounts
|
||||
if (img && displayDid) {
|
||||
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(displayDid)}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.avatar && img) {
|
||||
img.src = data.avatar;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
// Keep placeholder on error
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="comment-author-info">
|
||||
<span className="comment-author">
|
||||
{displayName || 'unknown'}
|
||||
</span>
|
||||
<a
|
||||
href={generateProfileUrl({ handle: displayHandle, did: displayDid })}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="comment-handle"
|
||||
>
|
||||
@{displayHandle || 'unknown'}
|
||||
</a>
|
||||
</div>
|
||||
<span className="comment-date">
|
||||
{new Date(record.value.createdAt).toLocaleString()}
|
||||
</span>
|
||||
<div className="comment-actions">
|
||||
<button
|
||||
onClick={() => toggleJsonDisplay(record.uri)}
|
||||
className="json-button"
|
||||
title="Show/Hide JSON"
|
||||
>
|
||||
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
|
||||
</button>
|
||||
<button className="chat-type-button">
|
||||
{record.value.type === 'question' ? 'Question' : 'Answer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="comment-meta">
|
||||
{record.value.post?.url && (
|
||||
<small><a href={record.value.post.url}>{record.value.post.url}</a></small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON Display */}
|
||||
{showJsonFor === record.uri && (
|
||||
<div className="json-display">
|
||||
<h5>JSON Record:</h5>
|
||||
<pre className="json-content">
|
||||
{JSON.stringify(record, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="comment-content">
|
||||
{record.value.text?.split('\n').map((line: string, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
{index < record.value.text.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
aiChatHistory.map((record, index) =>
|
||||
renderAIContent(record, index, 'comment-item')
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1588,7 +1562,7 @@ function App() {
|
||||
{activeTab === 'lang-en' && (
|
||||
<div className="comments-list">
|
||||
{langEnRecords.length === 0 ? (
|
||||
<p className="no-content">No English translations yet</p>
|
||||
<p className="no-content">No EN translations yet</p>
|
||||
) : (
|
||||
langEnRecords.map((record, index) =>
|
||||
renderAIContent(record, index, 'lang-item')
|
||||
@@ -1603,78 +1577,9 @@ function App() {
|
||||
{aiCommentRecords.length === 0 ? (
|
||||
<p className="no-content">No AI comments yet</p>
|
||||
) : (
|
||||
aiCommentRecords.map((record, index) => (
|
||||
<div key={index} className="comment-item">
|
||||
<div className="comment-header">
|
||||
<img
|
||||
src={generatePlaceholderAvatar('ai')}
|
||||
alt="AI Avatar"
|
||||
className="comment-avatar"
|
||||
ref={(img) => {
|
||||
// Fetch AI avatar
|
||||
const currentAiDid = aiDid || appConfig.aiDid;
|
||||
if (img && currentAiDid) {
|
||||
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(currentAiDid)}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.avatar && img) {
|
||||
img.src = data.avatar;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
// Keep placeholder on error
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="comment-author-info">
|
||||
<span className="comment-author">
|
||||
AI
|
||||
</span>
|
||||
<span className="comment-handle">
|
||||
@{aiProfile?.handle || 'yui.syui.ai'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="comment-date">
|
||||
{new Date(record.value.createdAt || record.value.generated_at).toLocaleString()}
|
||||
</span>
|
||||
<div className="comment-actions">
|
||||
<button
|
||||
onClick={() => toggleJsonDisplay(record.uri)}
|
||||
className="json-button"
|
||||
title="Show/Hide JSON"
|
||||
>
|
||||
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="comment-meta">
|
||||
{(record.value.post?.url || record.value.post_url) && (
|
||||
<small><a href={record.value.post?.url || record.value.post_url}>{record.value.post?.url || record.value.post_url}</a></small>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* JSON Display */}
|
||||
{showJsonFor === record.uri && (
|
||||
<div className="json-display">
|
||||
<h5>JSON Record:</h5>
|
||||
<pre className="json-content">
|
||||
{JSON.stringify(record, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="comment-content">
|
||||
{(record.value.text || record.value.comment)?.split('\n').map((line: string, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
{line}
|
||||
{index < (record.value.text || record.value.comment)?.split('\n').length - 1 && <br />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
aiCommentRecords.map((record, index) =>
|
||||
renderAIContent(record, index, 'comment-item')
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
@@ -191,6 +191,7 @@ Answer:`;
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://syui.ai',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
|
@@ -89,7 +89,7 @@ export function getAppConfig(): AppConfig {
|
||||
|
||||
// DIDsはハンドルから実行時に解決される(フォールバック用のみ保持)
|
||||
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
|
||||
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef';
|
||||
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
|
||||
const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
|
||||
const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
|
||||
const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
|
||||
@@ -113,7 +113,7 @@ export function getAppConfig(): AppConfig {
|
||||
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
|
||||
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
|
||||
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
|
||||
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
|
||||
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma3:4b';
|
||||
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
|
||||
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
|
||||
const atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||
|
@@ -12,6 +12,7 @@ interface AtprotoSession {
|
||||
|
||||
class AtprotoOAuthService {
|
||||
private oauthClient: BrowserOAuthClient | null = null;
|
||||
private oauthClientSyuIs: BrowserOAuthClient | null = null;
|
||||
private agent: Agent | null = null;
|
||||
private initializePromise: Promise<void> | null = null;
|
||||
|
||||
@@ -31,22 +32,27 @@ class AtprotoOAuthService {
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
|
||||
|
||||
// Generate client ID based on current origin
|
||||
const clientId = this.getClientId();
|
||||
|
||||
|
||||
// Support multiple PDS hosts for OAuth
|
||||
// Initialize both OAuth clients
|
||||
this.oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: clientId,
|
||||
handleResolver: 'https://bsky.social', // Default resolver
|
||||
handleResolver: 'https://bsky.social',
|
||||
plcDirectoryUrl: 'https://plc.directory',
|
||||
});
|
||||
|
||||
this.oauthClientSyuIs = await BrowserOAuthClient.load({
|
||||
clientId: clientId,
|
||||
handleResolver: 'https://syu.is',
|
||||
plcDirectoryUrl: 'https://plc.syu.is',
|
||||
});
|
||||
|
||||
|
||||
// Try to restore existing session
|
||||
const result = await this.oauthClient.init();
|
||||
// Try to restore existing session from either client
|
||||
let result = await this.oauthClient.init();
|
||||
if (!result?.session) {
|
||||
result = await this.oauthClientSyuIs.init();
|
||||
}
|
||||
if (result?.session) {
|
||||
|
||||
// Create Agent instance with proper configuration
|
||||
@@ -92,41 +98,13 @@ class AtprotoOAuthService {
|
||||
}
|
||||
|
||||
private async processSession(session: any): Promise<{ did: string; handle: string }> {
|
||||
|
||||
|
||||
// Log full session structure
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// Check if agent has properties we can access
|
||||
if (session.agent) {
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
const did = session.sub || session.did;
|
||||
let handle = session.handle || 'unknown';
|
||||
|
||||
// Create Agent directly with session (per official docs)
|
||||
try {
|
||||
this.agent = new Agent(session);
|
||||
|
||||
|
||||
// Check if agent has session info after creation
|
||||
|
||||
|
||||
|
||||
if (this.agent.session) {
|
||||
|
||||
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
// Fallback to dpopFetch method
|
||||
this.agent = new Agent({
|
||||
service: session.server?.serviceEndpoint || 'https://bsky.social',
|
||||
@@ -204,39 +182,15 @@ class AtprotoOAuthService {
|
||||
return `${origin}/client-metadata.json`;
|
||||
}
|
||||
|
||||
private detectPDSFromHandle(handle: string): string {
|
||||
|
||||
|
||||
// Supported PDS hosts and their corresponding handles
|
||||
const pdsMapping = {
|
||||
'syu.is': 'https://syu.is',
|
||||
'bsky.social': 'https://bsky.social',
|
||||
};
|
||||
|
||||
// Check if handle ends with known PDS domains
|
||||
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
|
||||
if (handle.endsWith(`.${domain}`)) {
|
||||
|
||||
return pdsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to bsky.social
|
||||
|
||||
return 'https://bsky.social';
|
||||
}
|
||||
|
||||
async initiateOAuthFlow(handle?: string): Promise<void> {
|
||||
try {
|
||||
|
||||
|
||||
if (!this.oauthClient) {
|
||||
|
||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
throw new Error('Failed to initialize OAuth client');
|
||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
||||
throw new Error('Failed to initialize OAuth clients');
|
||||
}
|
||||
|
||||
// If handle is not provided, prompt user
|
||||
@@ -247,49 +201,27 @@ class AtprotoOAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Detect PDS based on handle
|
||||
const pdsUrl = this.detectPDSFromHandle(handle);
|
||||
|
||||
|
||||
// Re-initialize OAuth client with correct PDS if needed
|
||||
if (pdsUrl !== 'https://bsky.social') {
|
||||
|
||||
this.oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: this.getClientId(),
|
||||
handleResolver: pdsUrl,
|
||||
});
|
||||
// Determine which OAuth client to use
|
||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
||||
let allowedHandles: string[] = [];
|
||||
try {
|
||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
||||
} catch {
|
||||
allowedHandles = [];
|
||||
}
|
||||
|
||||
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
|
||||
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
|
||||
|
||||
// Start OAuth authorization flow
|
||||
|
||||
|
||||
try {
|
||||
const authUrl = await this.oauthClient.authorize(handle, {
|
||||
const authUrl = await oauthClient.authorize(handle, {
|
||||
scope: 'atproto transition:generic',
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Store some debug info before redirect
|
||||
sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
handle: handle,
|
||||
authUrl: authUrl.toString(),
|
||||
currentUrl: window.location.href
|
||||
}));
|
||||
|
||||
// Redirect to authorization server
|
||||
|
||||
window.location.href = authUrl.toString();
|
||||
} catch (authorizeError) {
|
||||
|
||||
throw authorizeError;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
|
||||
}
|
||||
}
|
||||
@@ -345,22 +277,16 @@ class AtprotoOAuthService {
|
||||
|
||||
async checkSession(): Promise<{ did: string; handle: string } | null> {
|
||||
try {
|
||||
|
||||
|
||||
if (!this.oauthClient) {
|
||||
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const result = await this.oauthClient.init();
|
||||
|
||||
|
||||
if (result?.session) {
|
||||
// Use the common session processing method
|
||||
return this.processSession(result.session);
|
||||
@@ -424,28 +350,20 @@ class AtprotoOAuthService {
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
|
||||
|
||||
// Clear Agent
|
||||
this.agent = null;
|
||||
|
||||
|
||||
// Clear BrowserOAuthClient session
|
||||
if (this.oauthClient) {
|
||||
|
||||
try {
|
||||
// BrowserOAuthClient may have a revoke or signOut method
|
||||
if (typeof (this.oauthClient as any).signOut === 'function') {
|
||||
await (this.oauthClient as any).signOut();
|
||||
|
||||
} else if (typeof (this.oauthClient as any).revoke === 'function') {
|
||||
await (this.oauthClient as any).revoke();
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
} catch (oauthError) {
|
||||
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
// Reset the OAuth client to force re-initialization
|
||||
@@ -457,18 +375,16 @@ class AtprotoOAuthService {
|
||||
localStorage.removeItem('atproto_session');
|
||||
sessionStorage.clear();
|
||||
|
||||
// Clear all localStorage items that might be related to OAuth
|
||||
const keysToRemove: string[] = [];
|
||||
// Clear all OAuth-related storage
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => {
|
||||
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear internal session info
|
||||
(this as any)._sessionInfo = null;
|
||||
|
||||
|
||||
|
||||
|
135
oauth/src/tests/console-test.ts
Normal file
135
oauth/src/tests/console-test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
// Simple console test for OAuth app
|
||||
// This runs before 'npm run preview' to display test results
|
||||
|
||||
// Mock import.meta.env for Node.js environment
|
||||
(global as any).import = {
|
||||
meta: {
|
||||
env: {
|
||||
VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is',
|
||||
VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai',
|
||||
VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai',
|
||||
VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log',
|
||||
VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
||||
VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Simple implementation of functions for testing
|
||||
function detectPdsFromHandle(handle: string): string {
|
||||
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
|
||||
return 'syu.is';
|
||||
}
|
||||
if (handle.endsWith('.bsky.social')) {
|
||||
return 'bsky.social';
|
||||
}
|
||||
// Default case - check if it's in the allowed list
|
||||
const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
||||
if (allowedHandles.includes(handle)) {
|
||||
return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||
}
|
||||
return 'bsky.social';
|
||||
}
|
||||
|
||||
function getNetworkConfig(pds: string) {
|
||||
switch (pds) {
|
||||
case 'bsky.social':
|
||||
case 'bsky.app':
|
||||
return {
|
||||
pdsApi: `https://${pds}`,
|
||||
plcApi: 'https://plc.directory',
|
||||
bskyApi: 'https://public.api.bsky.app',
|
||||
webUrl: 'https://bsky.app'
|
||||
};
|
||||
case 'syu.is':
|
||||
return {
|
||||
pdsApi: 'https://syu.is',
|
||||
plcApi: 'https://plc.syu.is',
|
||||
bskyApi: 'https://bsky.syu.is',
|
||||
webUrl: 'https://web.syu.is'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
pdsApi: `https://${pds}`,
|
||||
plcApi: 'https://plc.directory',
|
||||
bskyApi: 'https://public.api.bsky.app',
|
||||
webUrl: 'https://bsky.app'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Main test execution
|
||||
console.log('\n=== OAuth App Configuration Tests ===\n');
|
||||
|
||||
// Test 1: Handle input behavior
|
||||
console.log('1. Handle Input → PDS Detection:');
|
||||
const testHandles = [
|
||||
'syui.ai',
|
||||
'syui.syu.is',
|
||||
'syui.syui.ai',
|
||||
'test.bsky.social',
|
||||
'unknown.handle'
|
||||
];
|
||||
|
||||
testHandles.forEach(handle => {
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
const config = getNetworkConfig(pds);
|
||||
console.log(` ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`);
|
||||
});
|
||||
|
||||
// Test 2: Environment variable impact
|
||||
console.log('\n2. Current Environment Configuration:');
|
||||
const env = (global as any).import.meta.env;
|
||||
console.log(` VITE_ATPROTO_PDS: ${env.VITE_ATPROTO_PDS}`);
|
||||
console.log(` VITE_ADMIN_HANDLE: ${env.VITE_ADMIN_HANDLE}`);
|
||||
console.log(` VITE_AI_HANDLE: ${env.VITE_AI_HANDLE}`);
|
||||
console.log(` VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`);
|
||||
console.log(` VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`);
|
||||
|
||||
// Test 3: API endpoint generation
|
||||
console.log('\n3. Generated API Endpoints:');
|
||||
const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE);
|
||||
const adminConfig = getNetworkConfig(adminPds);
|
||||
console.log(` Admin PDS detection: ${env.VITE_ADMIN_HANDLE} → ${adminPds}`);
|
||||
console.log(` Admin API endpoints:`);
|
||||
console.log(` - PDS API: ${adminConfig.pdsApi}`);
|
||||
console.log(` - Bsky API: ${adminConfig.bskyApi}`);
|
||||
console.log(` - Web URL: ${adminConfig.webUrl}`);
|
||||
|
||||
// Test 4: Collection URLs
|
||||
console.log('\n4. Collection API URLs:');
|
||||
const baseCollection = env.VITE_OAUTH_COLLECTION;
|
||||
console.log(` User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`);
|
||||
console.log(` Chat: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`);
|
||||
console.log(` Lang: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`);
|
||||
console.log(` Comment: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`);
|
||||
|
||||
// Test 5: OAuth routing logic
|
||||
console.log('\n5. OAuth Authorization Logic:');
|
||||
const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
||||
console.log(` Allowed handles: ${JSON.stringify(allowedHandles)}`);
|
||||
console.log(` OAuth scenarios:`);
|
||||
|
||||
const oauthTestCases = [
|
||||
'syui.ai', // Should use syu.is (in allowed list)
|
||||
'test.syu.is', // Should use syu.is (*.syu.is pattern)
|
||||
'user.bsky.social' // Should use bsky.social (default)
|
||||
];
|
||||
|
||||
oauthTestCases.forEach(handle => {
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
const isAllowed = allowedHandles.includes(handle);
|
||||
const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' :
|
||||
isAllowed ? 'in allowed list' :
|
||||
'default';
|
||||
console.log(` ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`);
|
||||
});
|
||||
|
||||
// Test 6: AI Profile Resolution
|
||||
console.log('\n6. AI Profile Resolution:');
|
||||
const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE);
|
||||
const aiConfig = getNetworkConfig(aiPds);
|
||||
console.log(` AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`);
|
||||
console.log(` AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`);
|
||||
|
||||
console.log('\n=== Tests Complete ===\n');
|
141
oauth/src/tests/oauth.test.ts
Normal file
141
oauth/src/tests/oauth.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { getAppConfig } from '../config/app';
|
||||
import { detectPdsFromHandle, getNetworkConfig } from '../App';
|
||||
|
||||
// Test helper to mock environment variables
|
||||
const mockEnv = (vars: Record<string, string>) => {
|
||||
Object.keys(vars).forEach(key => {
|
||||
(import.meta.env as any)[key] = vars[key];
|
||||
});
|
||||
};
|
||||
|
||||
describe('OAuth App Tests', () => {
|
||||
describe('Handle Input Behavior', () => {
|
||||
it('should detect PDS for syui.ai (Bluesky)', () => {
|
||||
const pds = detectPdsFromHandle('syui.ai');
|
||||
expect(pds).toBe('bsky.social');
|
||||
});
|
||||
|
||||
it('should detect PDS for syui.syu.is (syu.is)', () => {
|
||||
const pds = detectPdsFromHandle('syui.syu.is');
|
||||
expect(pds).toBe('syu.is');
|
||||
});
|
||||
|
||||
it('should detect PDS for syui.syui.ai (syu.is)', () => {
|
||||
const pds = detectPdsFromHandle('syui.syui.ai');
|
||||
expect(pds).toBe('syu.is');
|
||||
});
|
||||
|
||||
it('should use network config for different PDS', () => {
|
||||
const bskyConfig = getNetworkConfig('bsky.social');
|
||||
expect(bskyConfig.pdsApi).toBe('https://bsky.social');
|
||||
expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app');
|
||||
expect(bskyConfig.webUrl).toBe('https://bsky.app');
|
||||
|
||||
const syuisConfig = getNetworkConfig('syu.is');
|
||||
expect(syuisConfig.pdsApi).toBe('https://syu.is');
|
||||
expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is');
|
||||
expect(syuisConfig.webUrl).toBe('https://web.syu.is');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variable Changes', () => {
|
||||
beforeEach(() => {
|
||||
// Reset environment variables
|
||||
delete (import.meta.env as any).VITE_ATPROTO_PDS;
|
||||
delete (import.meta.env as any).VITE_ADMIN_HANDLE;
|
||||
delete (import.meta.env as any).VITE_AI_HANDLE;
|
||||
});
|
||||
|
||||
it('should use correct PDS for AI profile', () => {
|
||||
mockEnv({
|
||||
VITE_ATPROTO_PDS: 'syu.is',
|
||||
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
||||
VITE_AI_HANDLE: 'ai.syui.ai'
|
||||
});
|
||||
|
||||
const config = getAppConfig();
|
||||
expect(config.atprotoPds).toBe('syu.is');
|
||||
expect(config.adminHandle).toBe('ai.syui.ai');
|
||||
expect(config.aiHandle).toBe('ai.syui.ai');
|
||||
|
||||
// Network config should use syu.is endpoints
|
||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||
expect(networkConfig.bskyApi).toBe('https://bsky.syu.is');
|
||||
});
|
||||
|
||||
it('should construct correct API requests for admin userlist', () => {
|
||||
mockEnv({
|
||||
VITE_ATPROTO_PDS: 'syu.is',
|
||||
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
||||
VITE_OAUTH_COLLECTION: 'ai.syui.log'
|
||||
});
|
||||
|
||||
const config = getAppConfig();
|
||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||
const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`;
|
||||
|
||||
expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth Login Flow', () => {
|
||||
it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => {
|
||||
mockEnv({
|
||||
VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
||||
VITE_ATPROTO_PDS: 'syu.is'
|
||||
});
|
||||
|
||||
const config = getAppConfig();
|
||||
const handle = 'syui.ai';
|
||||
|
||||
// Check if handle is in allowed list
|
||||
expect(config.allowedHandles).toContain(handle);
|
||||
|
||||
// Should use configured PDS for OAuth
|
||||
const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`;
|
||||
expect(expectedAuthUrl).toContain('syu.is');
|
||||
});
|
||||
|
||||
it('should use syu.is OAuth for *.syu.is handles', () => {
|
||||
const handle = 'test.syu.is';
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
expect(pds).toBe('syu.is');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal display test output
|
||||
export function runTerminalTests() {
|
||||
console.log('\n=== OAuth App Tests ===\n');
|
||||
|
||||
// Test 1: Handle input behavior
|
||||
console.log('1. Handle Input Detection:');
|
||||
const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai'];
|
||||
handles.forEach(handle => {
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
console.log(` ${handle} → PDS: ${pds}`);
|
||||
});
|
||||
|
||||
// Test 2: Environment variable impact
|
||||
console.log('\n2. Environment Variables:');
|
||||
const config = getAppConfig();
|
||||
console.log(` VITE_ATPROTO_PDS: ${config.atprotoPds}`);
|
||||
console.log(` VITE_ADMIN_HANDLE: ${config.adminHandle}`);
|
||||
console.log(` VITE_AI_HANDLE: ${config.aiHandle}`);
|
||||
console.log(` VITE_OAUTH_COLLECTION: ${config.collections.base}`);
|
||||
|
||||
// Test 3: API endpoints
|
||||
console.log('\n3. API Endpoints:');
|
||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||
console.log(` Admin PDS API: ${networkConfig.pdsApi}`);
|
||||
console.log(` Admin Bsky API: ${networkConfig.bskyApi}`);
|
||||
console.log(` User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`);
|
||||
|
||||
// Test 4: OAuth routing
|
||||
console.log('\n4. OAuth Routing:');
|
||||
console.log(` Allowed handles: ${JSON.stringify(config.allowedHandles)}`);
|
||||
console.log(` OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`);
|
||||
|
||||
console.log('\n=== End Tests ===\n');
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
// PDS Detection and API URL mapping utilities
|
||||
|
||||
import { isValidDid, isValidHandle } from './validation';
|
||||
|
||||
export interface NetworkConfig {
|
||||
pdsApi: string;
|
||||
plcApi: string;
|
||||
@@ -9,12 +11,33 @@ export interface NetworkConfig {
|
||||
|
||||
// Detect PDS from handle
|
||||
export function detectPdsFromHandle(handle: string): string {
|
||||
if (handle.endsWith('.syu.is')) {
|
||||
// Get allowed handles from environment
|
||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
||||
let allowedHandles: string[] = [];
|
||||
try {
|
||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
||||
} catch {
|
||||
allowedHandles = [];
|
||||
}
|
||||
|
||||
// Get configured PDS from environment
|
||||
const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||
|
||||
// Check if handle is in allowed list
|
||||
if (allowedHandles.includes(handle)) {
|
||||
return configuredPds;
|
||||
}
|
||||
|
||||
// Check if handle ends with .syu.is or .syui.ai
|
||||
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
|
||||
return 'syu.is';
|
||||
}
|
||||
|
||||
// Check if handle ends with .bsky.social or .bsky.app
|
||||
if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
|
||||
return 'bsky.social';
|
||||
}
|
||||
|
||||
// Default to Bluesky for unknown domains
|
||||
return 'bsky.social';
|
||||
}
|
||||
@@ -74,8 +97,13 @@ export function getApiUrlForUser(handle: string): string {
|
||||
return config.bskyApi;
|
||||
}
|
||||
|
||||
// Resolve handle/DID to actual PDS endpoint using com.atproto.repo.describeRepo
|
||||
// Resolve handle/DID to actual PDS endpoint using PLC API first
|
||||
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
|
||||
// Validate input
|
||||
if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) {
|
||||
throw new Error(`Invalid identifier: ${handleOrDid}`);
|
||||
}
|
||||
|
||||
let targetDid = handleOrDid;
|
||||
let targetHandle = handleOrDid;
|
||||
|
||||
@@ -83,7 +111,7 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
|
||||
if (!handleOrDid.startsWith('did:')) {
|
||||
try {
|
||||
// Try multiple endpoints for handle resolution
|
||||
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is'];
|
||||
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
|
||||
let resolved = false;
|
||||
|
||||
for (const endpoint of resolveEndpoints) {
|
||||
@@ -108,7 +136,34 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
|
||||
}
|
||||
}
|
||||
|
||||
// Now use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
|
||||
// First, try PLC API to get the authoritative DID document
|
||||
const plcApis = ['https://plc.directory', 'https://plc.syu.is'];
|
||||
|
||||
for (const plcApi of plcApis) {
|
||||
try {
|
||||
const plcResponse = await fetch(`${plcApi}/${targetDid}`);
|
||||
if (plcResponse.ok) {
|
||||
const didDocument = await plcResponse.json();
|
||||
|
||||
// Find PDS service in DID document
|
||||
const pdsService = didDocument.service?.find((s: any) =>
|
||||
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
|
||||
);
|
||||
|
||||
if (pdsService && pdsService.serviceEndpoint) {
|
||||
return {
|
||||
pds: pdsService.serviceEndpoint,
|
||||
did: targetDid,
|
||||
handle: targetHandle
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
|
||||
const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
|
||||
|
||||
for (const pdsEndpoint of pdsEndpoints) {
|
||||
|
21
oauth/src/utils/validation.ts
Normal file
21
oauth/src/utils/validation.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Validation utilities for atproto identifiers
|
||||
|
||||
export function isValidDid(did: string): boolean {
|
||||
if (!did || typeof did !== 'string') return false;
|
||||
|
||||
// Basic DID format: did:method:identifier
|
||||
const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/;
|
||||
return didRegex.test(did);
|
||||
}
|
||||
|
||||
export function isValidHandle(handle: string): boolean {
|
||||
if (!handle || typeof handle !== 'string') return false;
|
||||
|
||||
// Basic handle format: subdomain.domain.tld
|
||||
const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
return handleRegex.test(handle);
|
||||
}
|
||||
|
||||
export function isValidAtprotoIdentifier(identifier: string): boolean {
|
||||
return isValidDid(identifier) || isValidHandle(identifier);
|
||||
}
|
10
oauth_new/.env
Normal file
10
oauth_new/.env
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
|
||||
# Development/Debug features
|
||||
VITE_ENABLE_TEST_UI=true
|
||||
VITE_ENABLE_DEBUG=true
|
10
oauth_new/.env.production
Normal file
10
oauth_new/.env.production
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
|
||||
# Production settings - Disable development features
|
||||
VITE_ENABLE_TEST_UI=false
|
||||
VITE_ENABLE_DEBUG=false
|
116
oauth_new/ASK_AI_INTEGRATION.md
Normal file
116
oauth_new/ASK_AI_INTEGRATION.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Ask-AI Integration Implementation
|
||||
|
||||
## 概要
|
||||
|
||||
oauth_new アプリに ask-AI 機能を統合しました。この機能により、ユーザーはAIと対話し、その結果を atproto に記録できます。
|
||||
|
||||
## 実装されたファイル
|
||||
|
||||
### 1. `/src/hooks/useAskAI.js`
|
||||
- ask-AI サーバーとの通信機能
|
||||
- atproto への putRecord 機能
|
||||
- チャット履歴の管理
|
||||
- イベント送信(blog との通信用)
|
||||
|
||||
### 2. `/src/components/AskAI.jsx`
|
||||
- チャット UI コンポーネント
|
||||
- 質問入力・回答表示
|
||||
- 認証チェック
|
||||
- IME 対応
|
||||
|
||||
### 3. `/src/App.jsx` の更新
|
||||
- AskAI コンポーネントの統合
|
||||
- Ask AI ボタンの追加
|
||||
- イベントリスナーの設定
|
||||
- blog との通信機能
|
||||
|
||||
## JSON 構造の記録
|
||||
|
||||
`./json/` ディレクトリに各 collection の構造を記録しました:
|
||||
|
||||
- `ai.syui.ai_user.json` - ユーザーリスト
|
||||
- `ai.syui.ai_chat.json` - チャット記録(空)
|
||||
- `syui.syui.ai_chat.json` - チャット記録(実データ)
|
||||
- `ai.syui.ai_chat_lang.json` - 翻訳記録
|
||||
- `ai.syui.ai_chat_comment.json` - コメント記録
|
||||
|
||||
## 実際の ai.syui.log.chat 構造
|
||||
|
||||
確認された実際の構造:
|
||||
|
||||
```json
|
||||
{
|
||||
"$type": "ai.syui.log.chat",
|
||||
"post": {
|
||||
"url": "https://syui.ai/",
|
||||
"date": "2025-06-18T02:16:04.609Z",
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": "syui.ai",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "質問またはAI回答テキスト",
|
||||
"type": "question|answer",
|
||||
"author": {
|
||||
"did": "did:plc:...",
|
||||
"handle": "handle名",
|
||||
"displayName": "表示名",
|
||||
"avatar": "アバターURL"
|
||||
},
|
||||
"createdAt": "2025-06-18T02:16:04.609Z"
|
||||
}
|
||||
```
|
||||
|
||||
## イベント通信
|
||||
|
||||
blog(ask-ai.js)と OAuth アプリ間の通信:
|
||||
|
||||
### 送信イベント
|
||||
- `postAIQuestion` - blog から OAuth アプリへ質問送信
|
||||
- `aiProfileLoaded` - OAuth アプリから blog へ AI プロフィール送信
|
||||
- `aiResponseReceived` - OAuth アプリから blog へ AI 回答送信
|
||||
|
||||
### 受信イベント
|
||||
- OAuth アプリが `postAIQuestion` を受信して処理
|
||||
- blog が `aiResponseReceived` を受信して表示
|
||||
|
||||
## 環境変数
|
||||
|
||||
```env
|
||||
VITE_ASK_AI_URL=http://localhost:3000/ask # ask-AI サーバーURL(デフォルト)
|
||||
VITE_ADMIN_HANDLE=ai.syui.ai
|
||||
VITE_ATPROTO_PDS=syu.is
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
```
|
||||
|
||||
## 機能
|
||||
|
||||
### 実装済み
|
||||
- ✅ ask-AI サーバーとの通信
|
||||
- ✅ atproto への question/answer record 保存
|
||||
- ✅ チャット履歴の表示・管理
|
||||
- ✅ blog との双方向イベント通信
|
||||
- ✅ 認証機能(ログイン必須)
|
||||
- ✅ エラーハンドリング・ローディング状態
|
||||
- ✅ 実際の JSON 構造に合わせた実装
|
||||
|
||||
### 今後のテスト項目
|
||||
- ask-AI サーバーの準備・起動
|
||||
- 実際の質問送信テスト
|
||||
- atproto への putRecord 動作確認
|
||||
- blog からの連携テスト
|
||||
|
||||
## 使用方法
|
||||
|
||||
1. 開発サーバー起動: `npm run dev`
|
||||
2. OAuth ログイン実行
|
||||
3. "Ask AI" ボタンをクリック
|
||||
4. チャット画面で質問入力
|
||||
5. AI の回答が表示され、atproto に記録される
|
||||
|
||||
## 注意事項
|
||||
|
||||
- ask-AI サーバー(VITE_ASK_AI_URL)が必要
|
||||
- 認証されたユーザーのみ質問可能
|
||||
- ai.syui.log.chat への書き込み権限が必要
|
||||
- Production 環境では logger が無効化される
|
174
oauth_new/AVATAR_SYSTEM.md
Normal file
174
oauth_new/AVATAR_SYSTEM.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Avatar Fetching System
|
||||
|
||||
This document describes the avatar fetching system implemented for the oauth_new application.
|
||||
|
||||
## Overview
|
||||
|
||||
The avatar system provides intelligent avatar fetching with fallback mechanisms, caching, and error handling. It follows the design specified in the project instructions:
|
||||
|
||||
1. **Primary Source**: Try to use avatar from record JSON first
|
||||
2. **Fallback**: If avatar is broken/missing, fetch fresh data from ATProto
|
||||
3. **Fresh Data Flow**: handle → PDS → DID → profile → avatar URI
|
||||
4. **Caching**: Avoid excessive API calls with intelligent caching
|
||||
|
||||
## Files Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── utils/
|
||||
│ └── avatar.js # Core avatar fetching logic
|
||||
├── components/
|
||||
│ ├── Avatar.jsx # React avatar component
|
||||
│ └── AvatarTest.jsx # Test component
|
||||
└── App.css # Avatar styling
|
||||
```
|
||||
|
||||
## Core Functions
|
||||
|
||||
### `getAvatar(options)`
|
||||
Main function to fetch avatar with intelligent fallback.
|
||||
|
||||
```javascript
|
||||
const avatar = await getAvatar({
|
||||
record: recordObject, // Optional: record containing avatar data
|
||||
handle: 'user.handle', // Required if no record
|
||||
did: 'did:plc:xxx', // Optional: user DID
|
||||
forceFresh: false // Optional: force fresh fetch
|
||||
})
|
||||
```
|
||||
|
||||
### `batchFetchAvatars(users)`
|
||||
Fetch avatars for multiple users in parallel with concurrency control.
|
||||
|
||||
```javascript
|
||||
const avatarMap = await batchFetchAvatars([
|
||||
{ handle: 'user1.handle', did: 'did:plc:xxx1' },
|
||||
{ handle: 'user2.handle', did: 'did:plc:xxx2' }
|
||||
])
|
||||
```
|
||||
|
||||
### `prefetchAvatar(handle)`
|
||||
Prefetch and cache avatar for a specific handle.
|
||||
|
||||
```javascript
|
||||
await prefetchAvatar('user.handle')
|
||||
```
|
||||
|
||||
## React Components
|
||||
|
||||
### `<Avatar>`
|
||||
Basic avatar component with loading states and fallbacks.
|
||||
|
||||
```jsx
|
||||
<Avatar
|
||||
record={record}
|
||||
handle="user.handle"
|
||||
did="did:plc:xxx"
|
||||
size={40}
|
||||
showFallback={true}
|
||||
onLoad={() => console.log('loaded')}
|
||||
onError={(err) => console.log('error', err)}
|
||||
/>
|
||||
```
|
||||
|
||||
### `<AvatarWithCard>`
|
||||
Avatar with hover card showing user information.
|
||||
|
||||
```jsx
|
||||
<AvatarWithCard
|
||||
record={record}
|
||||
displayName="User Name"
|
||||
apiConfig={apiConfig}
|
||||
size={60}
|
||||
/>
|
||||
```
|
||||
|
||||
### `<AvatarList>`
|
||||
Display multiple avatars with overlap effect.
|
||||
|
||||
```jsx
|
||||
<AvatarList
|
||||
users={userArray}
|
||||
maxDisplay={5}
|
||||
size={30}
|
||||
/>
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
1. **Record Check**: Extract avatar from record.value.author.avatar
|
||||
2. **URL Validation**: Verify avatar URL is accessible (HEAD request)
|
||||
3. **Fresh Fetch**: If broken, fetch fresh data:
|
||||
- Get PDS from handle using `getPdsFromHandle()`
|
||||
- Get API config using `getApiConfig()`
|
||||
- Get DID from PDS using `atproto.getDid()`
|
||||
- Get profile from bsky API using `atproto.getProfile()`
|
||||
- Extract avatar from profile
|
||||
4. **Cache**: Store result in cache with 30-minute TTL
|
||||
5. **Fallback**: Show initial-based fallback if no avatar found
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
- **Cache Key**: `avatar:{handle}`
|
||||
- **Duration**: 30 minutes (configurable)
|
||||
- **Cache Provider**: Uses existing `dataCache` utility
|
||||
- **Invalidation**: Manual cache clearing functions available
|
||||
|
||||
## Error Handling
|
||||
|
||||
- **Network Errors**: Gracefully handled with fallback UI
|
||||
- **Broken URLs**: Automatically detected and re-fetched fresh
|
||||
- **Missing Handles**: Throws descriptive error messages
|
||||
- **API Failures**: Logged but don't break UI
|
||||
|
||||
## Integration
|
||||
|
||||
The avatar system is integrated into the existing RecordList component:
|
||||
|
||||
```jsx
|
||||
// Old approach
|
||||
{record.value.author?.avatar && (
|
||||
<img src={record.value.author.avatar} alt="avatar" className="avatar" />
|
||||
)}
|
||||
|
||||
// New approach
|
||||
<Avatar
|
||||
record={record}
|
||||
handle={record.value.author?.handle}
|
||||
did={record.value.author?.did}
|
||||
size={40}
|
||||
showFallback={true}
|
||||
/>
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The system includes a comprehensive test component (`AvatarTest.jsx`) that can be accessed through the Test UI in the app. It demonstrates:
|
||||
|
||||
1. Avatar from record data
|
||||
2. Avatar from handle only
|
||||
3. Broken avatar URL handling
|
||||
4. Batch fetching
|
||||
5. Prefetch functionality
|
||||
6. Various avatar components
|
||||
|
||||
To test:
|
||||
1. Open the app
|
||||
2. Click "Test" button in header
|
||||
3. Switch to "Avatar System" tab
|
||||
4. Use the test controls to verify functionality
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Concurrent Fetching**: Batch operations use concurrency limits (5 parallel requests)
|
||||
- **Caching**: Reduces API calls by caching results
|
||||
- **Lazy Loading**: Avatar images use lazy loading
|
||||
- **Error Recovery**: Broken avatars are automatically retried with fresh data
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Persistent Cache**: Consider localStorage for cross-session caching
|
||||
2. **Image Optimization**: Add WebP support and size optimization
|
||||
3. **Preloading**: Implement smarter preloading strategies
|
||||
4. **CDN Integration**: Add CDN support for avatar delivery
|
||||
5. **Placeholder Variations**: More diverse fallback avatar styles
|
420
oauth_new/AVATAR_USAGE_EXAMPLES.md
Normal file
420
oauth_new/AVATAR_USAGE_EXAMPLES.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Avatar System Usage Examples
|
||||
|
||||
This document provides practical examples of how to use the avatar fetching system in your components.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Avatar Display
|
||||
|
||||
```jsx
|
||||
import Avatar from './components/Avatar.jsx'
|
||||
|
||||
function UserProfile({ user }) {
|
||||
return (
|
||||
<div className="user-profile">
|
||||
<Avatar
|
||||
handle={user.handle}
|
||||
did={user.did}
|
||||
size={80}
|
||||
alt={`${user.displayName}'s avatar`}
|
||||
/>
|
||||
<h3>{user.displayName}</h3>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Avatar from Record Data
|
||||
|
||||
```jsx
|
||||
function CommentItem({ record }) {
|
||||
return (
|
||||
<div className="comment">
|
||||
<Avatar
|
||||
record={record}
|
||||
size={40}
|
||||
showFallback={true}
|
||||
/>
|
||||
<div className="comment-content">
|
||||
<strong>{record.value.author.displayName}</strong>
|
||||
<p>{record.value.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Avatar with Hover Card
|
||||
|
||||
```jsx
|
||||
import { AvatarWithCard } from './components/Avatar.jsx'
|
||||
|
||||
function UserList({ users, apiConfig }) {
|
||||
return (
|
||||
<div className="user-list">
|
||||
{users.map(user => (
|
||||
<AvatarWithCard
|
||||
key={user.handle}
|
||||
handle={user.handle}
|
||||
did={user.did}
|
||||
displayName={user.displayName}
|
||||
apiConfig={apiConfig}
|
||||
size={50}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Programmatic Avatar Fetching
|
||||
|
||||
```jsx
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getAvatar, batchFetchAvatars } from './utils/avatar.js'
|
||||
|
||||
function useUserAvatars(users) {
|
||||
const [avatars, setAvatars] = useState(new Map())
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchAvatars() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const avatarMap = await batchFetchAvatars(users)
|
||||
setAvatars(avatarMap)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch avatars:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (users.length > 0) {
|
||||
fetchAvatars()
|
||||
}
|
||||
}, [users])
|
||||
|
||||
return { avatars, loading }
|
||||
}
|
||||
|
||||
// Usage
|
||||
function TeamDisplay({ team }) {
|
||||
const { avatars, loading } = useUserAvatars(team.members)
|
||||
|
||||
if (loading) return <div>Loading team...</div>
|
||||
|
||||
return (
|
||||
<div className="team">
|
||||
{team.members.map(member => (
|
||||
<img
|
||||
key={member.handle}
|
||||
src={avatars.get(member.handle) || '/default-avatar.png'}
|
||||
alt={member.displayName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Force Refresh Avatar
|
||||
|
||||
```jsx
|
||||
import { useState } from 'react'
|
||||
import Avatar from './components/Avatar.jsx'
|
||||
import { getAvatar, clearAvatarCache } from './utils/avatar.js'
|
||||
|
||||
function RefreshableAvatar({ handle, did }) {
|
||||
const [key, setKey] = useState(0)
|
||||
|
||||
const handleRefresh = async () => {
|
||||
// Clear cache for this user
|
||||
clearAvatarCache(handle)
|
||||
|
||||
// Force re-render of Avatar component
|
||||
setKey(prev => prev + 1)
|
||||
|
||||
// Optionally, prefetch fresh avatar
|
||||
try {
|
||||
await getAvatar({ handle, did, forceFresh: true })
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh avatar:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="refreshable-avatar">
|
||||
<Avatar
|
||||
key={key}
|
||||
handle={handle}
|
||||
did={did}
|
||||
size={60}
|
||||
/>
|
||||
<button onClick={handleRefresh}>
|
||||
Refresh Avatar
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Avatar List with Overflow
|
||||
|
||||
```jsx
|
||||
import { AvatarList } from './components/Avatar.jsx'
|
||||
|
||||
function ParticipantsList({ participants, maxVisible = 5 }) {
|
||||
return (
|
||||
<div className="participants">
|
||||
<h4>Participants ({participants.length})</h4>
|
||||
<AvatarList
|
||||
users={participants}
|
||||
maxDisplay={maxVisible}
|
||||
size={32}
|
||||
/>
|
||||
{participants.length > maxVisible && (
|
||||
<span className="overflow-text">
|
||||
and {participants.length - maxVisible} more...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Custom Error Handling
|
||||
|
||||
```jsx
|
||||
import { useState } from 'react'
|
||||
import Avatar from './components/Avatar.jsx'
|
||||
|
||||
function RobustAvatar({ handle, did, fallbackSrc }) {
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
const handleError = (error) => {
|
||||
console.warn(`Avatar failed for ${handle}:`, error)
|
||||
setHasError(true)
|
||||
}
|
||||
|
||||
if (hasError && fallbackSrc) {
|
||||
return (
|
||||
<img
|
||||
src={fallbackSrc}
|
||||
alt="Fallback avatar"
|
||||
className="avatar"
|
||||
onError={() => setHasError(false)} // Reset on fallback error
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
handle={handle}
|
||||
did={did}
|
||||
onError={handleError}
|
||||
showFallback={!hasError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
```jsx
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getAvatar } from './utils/avatar.js'
|
||||
|
||||
function AvatarWithCustomLoading({ handle, did }) {
|
||||
const [avatar, setAvatar] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAvatar() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const avatarUrl = await getAvatar({ handle, did })
|
||||
setAvatar(avatarUrl)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadAvatar()
|
||||
}, [handle, did])
|
||||
|
||||
if (loading) {
|
||||
return <div className="avatar-loading-spinner">Loading...</div>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="avatar-error">Failed to load avatar</div>
|
||||
}
|
||||
|
||||
if (!avatar) {
|
||||
return <div className="avatar-placeholder">No avatar</div>
|
||||
}
|
||||
|
||||
return <img src={avatar} alt="Avatar" className="avatar" />
|
||||
}
|
||||
```
|
||||
|
||||
## Optimization Patterns
|
||||
|
||||
### Preloading Strategy
|
||||
|
||||
```jsx
|
||||
import { useEffect } from 'react'
|
||||
import { prefetchAvatar } from './utils/avatar.js'
|
||||
|
||||
function UserCard({ user, isVisible }) {
|
||||
// Preload avatar when component becomes visible
|
||||
useEffect(() => {
|
||||
if (isVisible && user.handle) {
|
||||
prefetchAvatar(user.handle)
|
||||
}
|
||||
}, [isVisible, user.handle])
|
||||
|
||||
return (
|
||||
<div className="user-card">
|
||||
{isVisible && (
|
||||
<Avatar handle={user.handle} did={user.did} />
|
||||
)}
|
||||
<h4>{user.displayName}</h4>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Loading with Intersection Observer
|
||||
|
||||
```jsx
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Avatar from './components/Avatar.jsx'
|
||||
|
||||
function LazyAvatar({ handle, did, ...props }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const ref = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsVisible(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{isVisible ? (
|
||||
<Avatar handle={handle} did={did} {...props} />
|
||||
) : (
|
||||
<div className="avatar-placeholder" style={{
|
||||
width: props.size || 40,
|
||||
height: props.size || 40
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Cache Management
|
||||
|
||||
### Cache Statistics Display
|
||||
|
||||
```jsx
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getAvatarCacheStats, cleanupExpiredAvatars } from './utils/avatarCache.js'
|
||||
|
||||
function CacheStatsPanel() {
|
||||
const [stats, setStats] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const updateStats = () => {
|
||||
setStats(getAvatarCacheStats())
|
||||
}
|
||||
|
||||
updateStats()
|
||||
const interval = setInterval(updateStats, 5000) // Update every 5 seconds
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const cleaned = cleanupExpiredAvatars()
|
||||
alert(`Cleaned ${cleaned} expired cache entries`)
|
||||
setStats(getAvatarCacheStats())
|
||||
}
|
||||
|
||||
if (!stats) return null
|
||||
|
||||
return (
|
||||
<div className="cache-stats">
|
||||
<h4>Avatar Cache Stats</h4>
|
||||
<p>Cached avatars: {stats.totalCached}</p>
|
||||
<p>Cache hit rate: {stats.hitRate}%</p>
|
||||
<p>Cache hits: {stats.cacheHits}</p>
|
||||
<p>Cache misses: {stats.cacheMisses}</p>
|
||||
<button onClick={handleCleanup}>
|
||||
Clean Expired Cache
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Helpers
|
||||
|
||||
### Mock Avatar for Testing
|
||||
|
||||
```jsx
|
||||
// For testing environments
|
||||
const MockAvatar = ({ handle, size = 40, showFallback = true }) => {
|
||||
if (!showFallback) return null
|
||||
|
||||
const initial = (handle || 'U')[0].toUpperCase()
|
||||
|
||||
return (
|
||||
<div
|
||||
className="avatar-mock"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#e1e1e1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.4,
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Use in tests
|
||||
export default process.env.NODE_ENV === 'test' ? MockAvatar : Avatar
|
||||
```
|
||||
|
||||
These examples demonstrate the flexibility and power of the avatar system while maintaining good performance and user experience practices.
|
57
oauth_new/CLOUDFLARE_DEPLOY.yml
Normal file
57
oauth_new/CLOUDFLARE_DEPLOY.yml
Normal file
@@ -0,0 +1,57 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
OAUTH_DIR: oauth_new
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
npm ci
|
||||
|
||||
- name: Build OAuth app
|
||||
run: |
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
NODE_ENV=production npm run build
|
||||
env:
|
||||
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
|
||||
VITE_PDS: ${{ secrets.VITE_PDS }}
|
||||
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
|
||||
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
|
||||
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
|
||||
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
|
||||
VITE_ENABLE_TEST_UI: 'false'
|
||||
VITE_ENABLE_DEBUG: 'false'
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: ${{ env.OAUTH_DIR }}/dist
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
deploymentName: Production
|
104
oauth_new/CLOUDFLARE_DEPLOY_WITH_CLEANUP.yml
Normal file
104
oauth_new/CLOUDFLARE_DEPLOY_WITH_CLEANUP.yml
Normal file
@@ -0,0 +1,104 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
OAUTH_DIR: oauth_new
|
||||
KEEP_DEPLOYMENTS: 5 # 保持するデプロイメント数
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
npm ci
|
||||
|
||||
- name: Build OAuth app
|
||||
run: |
|
||||
cd ${{ env.OAUTH_DIR }}
|
||||
NODE_ENV=production npm run build
|
||||
env:
|
||||
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
|
||||
VITE_PDS: ${{ secrets.VITE_PDS }}
|
||||
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
|
||||
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
|
||||
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
|
||||
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
|
||||
VITE_ENABLE_TEST_UI: 'false'
|
||||
VITE_ENABLE_DEBUG: 'false'
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: ${{ env.OAUTH_DIR }}/dist
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
deploymentName: Production
|
||||
|
||||
cleanup:
|
||||
needs: deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- name: Wait for deployment to complete
|
||||
run: sleep 30
|
||||
|
||||
- name: Cleanup old deployments
|
||||
run: |
|
||||
# Get all deployments
|
||||
DEPLOYMENTS=$(curl -s -X GET \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
# Extract deployment IDs (skip the latest N deployments)
|
||||
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
|
||||
|
||||
if [ -z "$DEPLOYMENT_IDS" ]; then
|
||||
echo "No old deployments to delete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Delete old deployments
|
||||
for ID in $DEPLOYMENT_IDS; do
|
||||
echo "Deleting deployment: $ID"
|
||||
RESPONSE=$(curl -s -X DELETE \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
|
||||
if [ "$SUCCESS" = "true" ]; then
|
||||
echo "Successfully deleted deployment: $ID"
|
||||
else
|
||||
echo "Failed to delete deployment: $ID"
|
||||
echo "$RESPONSE" | jq .
|
||||
fi
|
||||
|
||||
sleep 1 # Rate limiting
|
||||
done
|
||||
|
||||
echo "Cleanup completed!"
|
178
oauth_new/DEPLOYMENT.md
Normal file
178
oauth_new/DEPLOYMENT.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 本番環境デプロイメント手順
|
||||
|
||||
## 本番環境用の調整
|
||||
|
||||
### 1. テスト機能の削除・無効化
|
||||
|
||||
本番環境では以下の調整が必要です:
|
||||
|
||||
#### A. TestUI コンポーネントの削除
|
||||
```jsx
|
||||
// src/App.jsx から以下を削除/コメントアウト
|
||||
import TestUI from './components/TestUI.jsx'
|
||||
const [showTestUI, setShowTestUI] = useState(false)
|
||||
|
||||
// ボトムセクションからTestUIを削除
|
||||
{showTestUI && (
|
||||
<TestUI />
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowTestUI(!showTestUI)}
|
||||
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
|
||||
>
|
||||
{showTestUI ? 'close test' : 'test'}
|
||||
</button>
|
||||
```
|
||||
|
||||
#### B. ログ出力の完全無効化
|
||||
現在は `logger.js` で開発環境のみログが有効になっていますが、完全に確実にするため:
|
||||
|
||||
```bash
|
||||
# 本番ビルド前に全てのconsole.logを確認
|
||||
grep -r "console\." src/ --exclude-dir=node_modules
|
||||
```
|
||||
|
||||
### 2. 環境変数の設定
|
||||
|
||||
#### 本番用 .env.production
|
||||
```bash
|
||||
VITE_ATPROTO_PDS=syu.is
|
||||
VITE_ADMIN_HANDLE=ai.syui.ai
|
||||
VITE_AI_HANDLE=ai.syui.ai
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
```
|
||||
|
||||
### 3. ビルドコマンド
|
||||
|
||||
```bash
|
||||
# 本番用ビルド
|
||||
npm run build
|
||||
|
||||
# 生成されるファイル確認
|
||||
ls -la dist/
|
||||
```
|
||||
|
||||
### 4. デプロイ用ファイル構成
|
||||
|
||||
```
|
||||
dist/
|
||||
├── index.html # 最小化HTML
|
||||
├── assets/
|
||||
│ ├── comment-atproto-[hash].js # メインJSバンドル
|
||||
│ └── comment-atproto-[hash].css # CSS
|
||||
```
|
||||
|
||||
### 5. ailog サイトへの統合
|
||||
|
||||
#### A. アセットファイルのコピー
|
||||
```bash
|
||||
# distファイルをailogサイトの適切な場所にコピー
|
||||
cp dist/assets/* /path/to/ailog/static/assets/
|
||||
cp dist/index.html /path/to/ailog/templates/oauth-assets.html
|
||||
```
|
||||
|
||||
#### B. ailog テンプレートでの読み込み
|
||||
```html
|
||||
<!-- ailog のテンプレートに追加 -->
|
||||
{{ if .Site.Params.oauth_comments }}
|
||||
{{ partial "oauth-assets.html" . }}
|
||||
{{ end }}
|
||||
```
|
||||
|
||||
### 6. 本番環境チェックリスト
|
||||
|
||||
#### ✅ セキュリティ
|
||||
- [ ] OAuth認証のリダイレクトURL確認
|
||||
- [ ] 環境変数の機密情報確認
|
||||
- [ ] HTTPS通信確認
|
||||
|
||||
#### ✅ パフォーマンス
|
||||
- [ ] バンドルサイズ確認(現在1.2MB)
|
||||
- [ ] ファイル圧縮確認
|
||||
- [ ] キャッシュ設定確認
|
||||
|
||||
#### ✅ 機能
|
||||
- [ ] 本番PDS接続確認
|
||||
- [ ] OAuth認証フロー確認
|
||||
- [ ] コメント投稿・表示確認
|
||||
- [ ] アバター表示確認
|
||||
|
||||
#### ✅ UI/UX
|
||||
- [ ] モバイル表示確認
|
||||
- [ ] アクセシビリティ確認
|
||||
- [ ] エラーハンドリング確認
|
||||
|
||||
### 7. 段階的デプロイ戦略
|
||||
|
||||
#### Phase 1: テスト環境
|
||||
```bash
|
||||
# テスト用のサブドメインでデプロイ
|
||||
# test.syui.ai など
|
||||
```
|
||||
|
||||
#### Phase 2: 本番環境
|
||||
```bash
|
||||
# 問題なければ本番環境にデプロイ
|
||||
# ailog本体に統合
|
||||
```
|
||||
|
||||
### 8. トラブルシューティング
|
||||
|
||||
#### よくある問題
|
||||
1. **OAuth認証エラー**: リダイレクトURL設定確認
|
||||
2. **PDS接続エラー**: ネットワーク・DNS設定確認
|
||||
3. **アバター表示エラー**: CORS設定確認
|
||||
4. **CSS競合**: oauth-プレフィックス確認
|
||||
|
||||
#### ログ確認方法
|
||||
```bash
|
||||
# 本番環境でエラーが発生した場合
|
||||
# ブラウザのDevToolsでエラー確認
|
||||
# logger.jsは本番では無効化されている
|
||||
```
|
||||
|
||||
### 9. 本番用設定ファイル
|
||||
|
||||
```bash
|
||||
# ~/.config/syui/ai/log/config.json
|
||||
{
|
||||
"oauth": {
|
||||
"environment": "production",
|
||||
"debug": false,
|
||||
"test_mode": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10. 推奨デプロイ手順
|
||||
|
||||
```bash
|
||||
# 1. テスト機能削除
|
||||
git checkout -b production-ready
|
||||
# App.jsx からTestUI関連を削除
|
||||
|
||||
# 2. 本番ビルド
|
||||
npm run build
|
||||
|
||||
# 3. ファイル確認
|
||||
ls -la dist/
|
||||
|
||||
# 4. ailogサイトに統合
|
||||
cp dist/assets/* ../my-blog/static/assets/
|
||||
cp dist/index.html ../my-blog/templates/oauth-assets.html
|
||||
|
||||
# 5. ailogサイトでテスト
|
||||
cd ../my-blog
|
||||
hugo server
|
||||
|
||||
# 6. 問題なければcommit
|
||||
git add .
|
||||
git commit -m "Production build: Remove test UI, optimize for deployment"
|
||||
```
|
||||
|
||||
## 注意事項
|
||||
|
||||
- TestUIは開発・デモ用のため本番では削除必須
|
||||
- loggerは自動で本番では無効化される
|
||||
- OAuth設定は本番PDS用に調整必要
|
||||
- バンドルサイズが大きいため今後最適化検討
|
334
oauth_new/DEVELOPMENT.md
Normal file
334
oauth_new/DEVELOPMENT.md
Normal file
@@ -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 <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])
|
||||
}
|
||||
```
|
110
oauth_new/ENV_SETUP.md
Normal file
110
oauth_new/ENV_SETUP.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 環境変数による機能切り替え
|
||||
|
||||
## 概要
|
||||
|
||||
開発用機能(TestUI、デバッグログ)をenv変数で簡単に有効/無効化できるようになりました。
|
||||
|
||||
## 設定ファイル
|
||||
|
||||
### 開発環境用: `.env`
|
||||
```bash
|
||||
# Development/Debug features
|
||||
VITE_ENABLE_TEST_UI=true
|
||||
VITE_ENABLE_DEBUG=true
|
||||
```
|
||||
|
||||
### 本番環境用: `.env.production`
|
||||
```bash
|
||||
# Production settings - Disable development features
|
||||
VITE_ENABLE_TEST_UI=false
|
||||
VITE_ENABLE_DEBUG=false
|
||||
```
|
||||
|
||||
## 制御される機能
|
||||
|
||||
### 1. TestUI コンポーネント
|
||||
- **制御**: `VITE_ENABLE_TEST_UI`
|
||||
- **true**: TestボタンとTestUI表示
|
||||
- **false**: TestUI関連が完全に非表示
|
||||
|
||||
### 2. デバッグログ
|
||||
- **制御**: `VITE_ENABLE_DEBUG`
|
||||
- **true**: console.log等が有効
|
||||
- **false**: すべてのlogが無効化
|
||||
|
||||
## 使い方
|
||||
|
||||
### 開発時
|
||||
```bash
|
||||
# .envで有効化されているので通常通り
|
||||
npm run dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 本番デプロイ時
|
||||
```bash
|
||||
# 自動的に .env.production が読み込まれる
|
||||
npm run build
|
||||
|
||||
# または明示的に指定
|
||||
NODE_ENV=production npm run build
|
||||
```
|
||||
|
||||
### 手動切り替え
|
||||
```bash
|
||||
# 一時的にTestUIだけ無効化
|
||||
VITE_ENABLE_TEST_UI=false npm run dev
|
||||
|
||||
# 一時的にデバッグだけ無効化
|
||||
VITE_ENABLE_DEBUG=false npm run dev
|
||||
```
|
||||
|
||||
## 実装詳細
|
||||
|
||||
### App.jsx
|
||||
```jsx
|
||||
// Environment-based feature flags
|
||||
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
|
||||
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
||||
|
||||
// TestUI表示制御
|
||||
{ENABLE_TEST_UI && showTestUI && (
|
||||
<div className="test-section">
|
||||
<TestUI />
|
||||
</div>
|
||||
)}
|
||||
|
||||
// Testボタン表示制御
|
||||
{ENABLE_TEST_UI && (
|
||||
<div className="bottom-actions">
|
||||
<button onClick={() => setShowTestUI(!showTestUI)}>
|
||||
{showTestUI ? 'close test' : 'test'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### logger.js
|
||||
```jsx
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.isDev = import.meta.env.DEV || false
|
||||
this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
||||
this.isEnabled = this.isDev && this.debugEnabled
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## メリット
|
||||
|
||||
✅ **コード削除不要**: 機能を残したまま本番で無効化
|
||||
✅ **簡単切り替え**: env変数だけで制御
|
||||
✅ **自動化対応**: CI/CDで環境別自動ビルド可能
|
||||
✅ **デバッグ容易**: 必要時に即座に有効化可能
|
||||
|
||||
## 本番デプロイチェックリスト
|
||||
|
||||
- [ ] `.env.production`でTestUI無効化確認
|
||||
- [ ] `.env.production`でデバッグ無効化確認
|
||||
- [ ] 本番ビルドでTestボタン非表示確認
|
||||
- [ ] 本番でconsole.log出力なし確認
|
444
oauth_new/IMPLEMENTATION_GUIDE.md
Normal file
444
oauth_new/IMPLEMENTATION_GUIDE.md
Normal file
@@ -0,0 +1,444 @@
|
||||
# OAuth_new 実装ガイド
|
||||
|
||||
## Claude Code用実装指示
|
||||
|
||||
### 即座に実装可能な改善(優先度:最高)
|
||||
|
||||
#### 1. エラーハンドリング強化
|
||||
|
||||
**ファイル**: `src/utils/errorHandler.js` (新規作成)
|
||||
```javascript
|
||||
export class ATProtoError extends Error {
|
||||
constructor(message, status, context) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.context = context
|
||||
this.timestamp = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(error) {
|
||||
if (error.status === 400) {
|
||||
return 'アカウントまたはコレクションが見つかりません'
|
||||
} else if (error.status === 429) {
|
||||
return 'レート制限です。しばらく待ってから再試行してください'
|
||||
} else if (error.status === 500) {
|
||||
return 'サーバーエラーが発生しました'
|
||||
} else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
|
||||
return 'ネットワーク接続を確認してください'
|
||||
} else if (error.message.includes('timeout')) {
|
||||
return 'タイムアウトしました。再試行してください'
|
||||
}
|
||||
return '予期しないエラーが発生しました'
|
||||
}
|
||||
|
||||
export function logError(error, context) {
|
||||
console.error(`[ATProto Error] ${context}:`, {
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**修正**: `src/api/atproto.js`
|
||||
```javascript
|
||||
import { ATProtoError, logError } from '../utils/errorHandler.js'
|
||||
|
||||
async function request(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(url, options)
|
||||
if (!response.ok) {
|
||||
throw new ATProtoError(
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
response.status,
|
||||
{ url, options }
|
||||
)
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (error instanceof ATProtoError) {
|
||||
logError(error, 'API Request')
|
||||
throw error
|
||||
}
|
||||
|
||||
// Network errors
|
||||
const atprotoError = new ATProtoError(
|
||||
'ネットワークエラーが発生しました',
|
||||
0,
|
||||
{ url, originalError: error.message }
|
||||
)
|
||||
logError(atprotoError, 'Network Error')
|
||||
throw atprotoError
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**修正**: `src/hooks/useAdminData.js`
|
||||
```javascript
|
||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
|
||||
|
||||
// loadAdminData関数内のcatchブロック
|
||||
} catch (err) {
|
||||
logError(err, 'useAdminData.loadAdminData')
|
||||
setError(getErrorMessage(err))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. シンプルなキャッシュシステム
|
||||
|
||||
**ファイル**: `src/utils/cache.js` (新規作成)
|
||||
```javascript
|
||||
class SimpleCache {
|
||||
constructor(ttl = 30000) { // 30秒TTL
|
||||
this.cache = new Map()
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
if (Date.now() - item.timestamp > this.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
return item.data
|
||||
}
|
||||
|
||||
set(key, data) {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
invalidatePattern(pattern) {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dataCache = new SimpleCache()
|
||||
```
|
||||
|
||||
**修正**: `src/api/atproto.js`
|
||||
```javascript
|
||||
import { dataCache } from '../utils/cache.js'
|
||||
|
||||
export const collections = {
|
||||
async getBase(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = `base:${pds}:${repo}:${collection}`
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getLang(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = `lang:${pds}:${repo}:${collection}`
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getComment(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = `comment:${pds}:${repo}:${collection}`
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
// 投稿後にキャッシュをクリア
|
||||
invalidateCache(collection) {
|
||||
dataCache.invalidatePattern(collection)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. ローディングスケルトン
|
||||
|
||||
**ファイル**: `src/components/LoadingSkeleton.jsx` (新規作成)
|
||||
```javascript
|
||||
import React from 'react'
|
||||
|
||||
export default function LoadingSkeleton({ count = 3 }) {
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
{Array(count).fill(0).map((_, i) => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar"></div>
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line"></div>
|
||||
<div className="skeleton-line short"></div>
|
||||
<div className="skeleton-line shorter"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<style jsx>{`
|
||||
.loading-skeleton {
|
||||
padding: 10px;
|
||||
}
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
border: 1px solid #eee;
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
.skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
margin-right: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
}
|
||||
.skeleton-line {
|
||||
height: 12px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.skeleton-line.short {
|
||||
width: 70%;
|
||||
}
|
||||
.skeleton-line.shorter {
|
||||
width: 40%;
|
||||
}
|
||||
@keyframes loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**修正**: `src/components/RecordTabs.jsx`
|
||||
```javascript
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
// RecordTabsコンポーネント内
|
||||
{activeTab === 'lang' && (
|
||||
loading ? (
|
||||
<LoadingSkeleton count={3} />
|
||||
) : (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
|
||||
records={filteredLangRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
```
|
||||
|
||||
### 中期実装(1週間以内)
|
||||
|
||||
#### 4. リトライ機能
|
||||
|
||||
**修正**: `src/api/atproto.js`
|
||||
```javascript
|
||||
async function requestWithRetry(url, options = {}, maxRetries = 3) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
return await request(url, options)
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) throw error
|
||||
|
||||
// 429 (レート制限) の場合は長めに待機
|
||||
const baseDelay = error.status === 429 ? 5000 : 1000
|
||||
const delay = Math.min(baseDelay * Math.pow(2, i), 30000)
|
||||
|
||||
console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全てのAPI呼び出しでrequestをrequestWithRetryに変更
|
||||
export const atproto = {
|
||||
async getDid(pds, handle) {
|
||||
const res = await requestWithRetry(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
|
||||
return res.did
|
||||
},
|
||||
// ...他のメソッドも同様に変更
|
||||
}
|
||||
```
|
||||
|
||||
#### 5. 段階的ローディング
|
||||
|
||||
**修正**: `src/hooks/useAdminData.js`
|
||||
```javascript
|
||||
export function useAdminData() {
|
||||
const [adminData, setAdminData] = useState({
|
||||
did: '',
|
||||
profile: null,
|
||||
records: [],
|
||||
apiConfig: null
|
||||
})
|
||||
const [langRecords, setLangRecords] = useState([])
|
||||
const [commentRecords, setCommentRecords] = useState([])
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
admin: true,
|
||||
base: true,
|
||||
lang: true,
|
||||
comment: true
|
||||
})
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAdminData()
|
||||
}, [])
|
||||
|
||||
const loadAdminData = async () => {
|
||||
try {
|
||||
setError(null)
|
||||
|
||||
// Phase 1: 管理者情報を最初に取得
|
||||
setLoadingStates(prev => ({ ...prev, admin: true }))
|
||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
||||
const did = await atproto.getDid(env.pds, env.admin)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
setAdminData({ did, profile, records: [], apiConfig })
|
||||
setLoadingStates(prev => ({ ...prev, admin: false }))
|
||||
|
||||
// Phase 2: 基本レコードを取得
|
||||
setLoadingStates(prev => ({ ...prev, base: true }))
|
||||
const records = await collections.getBase(apiConfig.pds, did, env.collection)
|
||||
setAdminData(prev => ({ ...prev, records }))
|
||||
setLoadingStates(prev => ({ ...prev, base: false }))
|
||||
|
||||
// Phase 3: lang/commentを並列取得
|
||||
const langPromise = collections.getLang(apiConfig.pds, did, env.collection)
|
||||
.then(data => {
|
||||
setLangRecords(data)
|
||||
setLoadingStates(prev => ({ ...prev, lang: false }))
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('Failed to load lang records:', err)
|
||||
setLoadingStates(prev => ({ ...prev, lang: false }))
|
||||
})
|
||||
|
||||
const commentPromise = collections.getComment(apiConfig.pds, did, env.collection)
|
||||
.then(data => {
|
||||
setCommentRecords(data)
|
||||
setLoadingStates(prev => ({ ...prev, comment: false }))
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('Failed to load comment records:', err)
|
||||
setLoadingStates(prev => ({ ...prev, comment: false }))
|
||||
})
|
||||
|
||||
await Promise.all([langPromise, commentPromise])
|
||||
|
||||
} catch (err) {
|
||||
logError(err, 'useAdminData.loadAdminData')
|
||||
setError(getErrorMessage(err))
|
||||
// エラー時もローディング状態を解除
|
||||
setLoadingStates({
|
||||
admin: false,
|
||||
base: false,
|
||||
lang: false,
|
||||
comment: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
loading: Object.values(loadingStates).some(Boolean),
|
||||
loadingStates,
|
||||
error,
|
||||
refresh: loadAdminData
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 緊急時対応
|
||||
|
||||
#### フォールバック機能
|
||||
|
||||
**修正**: `src/hooks/useAdminData.js`
|
||||
```javascript
|
||||
// エラー時でも基本機能を維持
|
||||
const loadWithFallback = async () => {
|
||||
try {
|
||||
await loadAdminData()
|
||||
} catch (err) {
|
||||
// フォールバック:最低限の表示を維持
|
||||
setAdminData({
|
||||
did: env.admin, // ハンドルをDIDとして使用
|
||||
profile: {
|
||||
handle: env.admin,
|
||||
displayName: env.admin,
|
||||
avatar: null
|
||||
},
|
||||
records: [],
|
||||
apiConfig: getApiConfig(`https://${env.pds}`)
|
||||
})
|
||||
setError('一部機能が利用できません。基本表示で継続します。')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 実装チェックリスト
|
||||
|
||||
### Phase 1 (即座実装)
|
||||
- [ ] `src/utils/errorHandler.js` 作成
|
||||
- [ ] `src/utils/cache.js` 作成
|
||||
- [ ] `src/components/LoadingSkeleton.jsx` 作成
|
||||
- [ ] `src/api/atproto.js` エラーハンドリング追加
|
||||
- [ ] `src/hooks/useAdminData.js` エラーハンドリング改善
|
||||
- [ ] `src/components/RecordTabs.jsx` ローディング表示追加
|
||||
|
||||
### Phase 2 (1週間以内)
|
||||
- [ ] `src/api/atproto.js` リトライ機能追加
|
||||
- [ ] `src/hooks/useAdminData.js` 段階的ローディング実装
|
||||
- [ ] キャッシュクリア機能の投稿フォーム統合
|
||||
|
||||
### テスト項目
|
||||
- [ ] エラー状態でも最低限表示される
|
||||
- [ ] キャッシュが適切に動作する
|
||||
- [ ] ローディング表示が適切に出る
|
||||
- [ ] リトライが正常に動作する
|
||||
|
||||
## パフォーマンス目標
|
||||
|
||||
- **初期表示**: 3秒 → 1秒
|
||||
- **キャッシュヒット率**: 70%以上
|
||||
- **エラー率**: 10% → 2%以下
|
||||
- **ユーザー体験**: ローディング状態が常に可視化
|
||||
|
||||
この実装により、./oauthで発生している「同じ問題の繰り返し」を避け、
|
||||
安定した成長可能なシステムが構築できます。
|
448
oauth_new/IMPROVEMENT_PLAN.md
Normal file
448
oauth_new/IMPROVEMENT_PLAN.md
Normal file
@@ -0,0 +1,448 @@
|
||||
# OAuth_new 改善計画
|
||||
|
||||
## 現状分析
|
||||
|
||||
### 良い点
|
||||
- ✅ クリーンなアーキテクチャ(Hooks分離)
|
||||
- ✅ 公式ライブラリ使用(@atproto/oauth-client-browser)
|
||||
- ✅ 適切なエラーハンドリング
|
||||
- ✅ 包括的なドキュメント
|
||||
- ✅ 環境変数による設定外部化
|
||||
|
||||
### 問題点
|
||||
- ❌ パフォーマンス:毎回全データを並列取得
|
||||
- ❌ UX:ローディング状態が分かりにくい
|
||||
- ❌ スケーラビリティ:データ量増加への対応不足
|
||||
- ❌ エラー詳細度:汎用的すぎるエラーメッセージ
|
||||
- ❌ リアルタイム性:手動更新が必要
|
||||
|
||||
## 改善計画
|
||||
|
||||
### Phase 1: 安定性・パフォーマンス向上(優先度:高)
|
||||
|
||||
#### 1.1 キャッシュシステム導入
|
||||
```javascript
|
||||
// 新規ファイル: src/utils/cache.js
|
||||
export class DataCache {
|
||||
constructor(ttl = 30000) { // 30秒TTL
|
||||
this.cache = new Map()
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
if (Date.now() - item.timestamp > this.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
return item.data
|
||||
}
|
||||
|
||||
set(key, data) {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
invalidate(pattern) {
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 リトライ機能付きAPI
|
||||
```javascript
|
||||
// 修正: src/api/atproto.js
|
||||
async function requestWithRetry(url, options = {}, maxRetries = 3) {
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const response = await fetch(url, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (i === maxRetries - 1) throw error
|
||||
|
||||
// 指数バックオフ
|
||||
const delay = Math.min(1000 * Math.pow(2, i), 10000)
|
||||
await new Promise(resolve => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 詳細なエラーハンドリング
|
||||
```javascript
|
||||
// 新規ファイル: src/utils/errorHandler.js
|
||||
export class ATProtoError extends Error {
|
||||
constructor(message, status, context) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.context = context
|
||||
this.timestamp = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(error) {
|
||||
if (error.status === 400) {
|
||||
return 'アカウントまたはコレクションが見つかりません'
|
||||
} else if (error.status === 429) {
|
||||
return 'レート制限です。しばらく待ってから再試行してください'
|
||||
} else if (error.status === 500) {
|
||||
return 'サーバーエラーが発生しました'
|
||||
} else if (error.message.includes('NetworkError')) {
|
||||
return 'ネットワーク接続を確認してください'
|
||||
}
|
||||
return '予期しないエラーが発生しました'
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: UX改善(優先度:中)
|
||||
|
||||
#### 2.1 ローディング状態の改善
|
||||
```javascript
|
||||
// 修正: src/components/RecordTabs.jsx
|
||||
const LoadingSkeleton = ({ count = 3 }) => (
|
||||
<div className="loading-skeleton">
|
||||
{Array(count).fill(0).map((_, i) => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar"></div>
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line"></div>
|
||||
<div className="skeleton-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// CSS追加
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 10px;
|
||||
border: 1px solid #eee;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: loading 1.5s infinite;
|
||||
}
|
||||
@keyframes loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 インクリメンタルローディング
|
||||
```javascript
|
||||
// 修正: src/hooks/useAdminData.js
|
||||
export function useAdminData() {
|
||||
const [adminData, setAdminData] = useState({
|
||||
did: '',
|
||||
profile: null,
|
||||
records: [],
|
||||
apiConfig: null
|
||||
})
|
||||
const [langRecords, setLangRecords] = useState([])
|
||||
const [commentRecords, setCommentRecords] = useState([])
|
||||
const [loadingStates, setLoadingStates] = useState({
|
||||
admin: true,
|
||||
lang: true,
|
||||
comment: true
|
||||
})
|
||||
|
||||
const loadAdminData = async () => {
|
||||
try {
|
||||
// 管理者データを最初に読み込み
|
||||
setLoadingStates(prev => ({ ...prev, admin: true }))
|
||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
||||
const did = await atproto.getDid(env.pds, env.admin)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
setAdminData({ did, profile, records: [], apiConfig })
|
||||
setLoadingStates(prev => ({ ...prev, admin: false }))
|
||||
|
||||
// 基本レコードを読み込み
|
||||
const records = await collections.getBase(apiConfig.pds, did, env.collection)
|
||||
setAdminData(prev => ({ ...prev, records }))
|
||||
|
||||
// lang/commentを並列で読み込み
|
||||
const [lang, comment] = await Promise.all([
|
||||
collections.getLang(apiConfig.pds, did, env.collection)
|
||||
.finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))),
|
||||
collections.getComment(apiConfig.pds, did, env.collection)
|
||||
.finally(() => setLoadingStates(prev => ({ ...prev, comment: false })))
|
||||
])
|
||||
|
||||
setLangRecords(lang)
|
||||
setCommentRecords(comment)
|
||||
} catch (err) {
|
||||
// エラーハンドリング
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
loadingStates,
|
||||
refresh: loadAdminData
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: リアルタイム機能(優先度:中)
|
||||
|
||||
#### 3.1 WebSocket統合
|
||||
```javascript
|
||||
// 新規ファイル: src/hooks/useRealtimeUpdates.js
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
export function useRealtimeUpdates(collection, onNewRecord) {
|
||||
const [connected, setConnected] = useState(false)
|
||||
const wsRef = useRef(null)
|
||||
const reconnectTimeoutRef = useRef(null)
|
||||
|
||||
const connect = () => {
|
||||
try {
|
||||
wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
setConnected(true)
|
||||
console.log('WebSocket connected')
|
||||
|
||||
// Subscribe to specific collection
|
||||
wsRef.current.send(JSON.stringify({
|
||||
type: 'subscribe',
|
||||
collections: [collection]
|
||||
}))
|
||||
}
|
||||
|
||||
wsRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.collection === collection && data.commit?.operation === 'create') {
|
||||
onNewRecord(data.commit.record)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to parse WebSocket message:', err)
|
||||
}
|
||||
}
|
||||
|
||||
wsRef.current.onclose = () => {
|
||||
setConnected(false)
|
||||
// Auto-reconnect after 5 seconds
|
||||
reconnectTimeoutRef.current = setTimeout(connect, 5000)
|
||||
}
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
setConnected(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to connect WebSocket:', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [collection])
|
||||
|
||||
return { connected }
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 オプティミスティック更新
|
||||
```javascript
|
||||
// 修正: src/components/CommentForm.jsx
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!text.trim() || !url.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// オプティミスティック更新用の仮レコード
|
||||
const optimisticRecord = {
|
||||
uri: `temp-${Date.now()}`,
|
||||
cid: 'temp',
|
||||
value: {
|
||||
$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()
|
||||
}
|
||||
}
|
||||
|
||||
// UIに即座に反映
|
||||
if (onOptimisticUpdate) {
|
||||
onOptimisticUpdate(optimisticRecord)
|
||||
}
|
||||
|
||||
try {
|
||||
const record = {
|
||||
repo: user.did,
|
||||
collection: env.collection,
|
||||
rkey: `comment-${Date.now()}`,
|
||||
record: optimisticRecord.value
|
||||
}
|
||||
|
||||
await atproto.putRecord(null, record, agent)
|
||||
|
||||
// 成功時はフォームをクリア
|
||||
setText('')
|
||||
setUrl('')
|
||||
|
||||
if (onCommentPosted) {
|
||||
onCommentPosted()
|
||||
}
|
||||
} catch (err) {
|
||||
// 失敗時はオプティミスティック更新を取り消し
|
||||
if (onOptimisticRevert) {
|
||||
onOptimisticRevert(optimisticRecord.uri)
|
||||
}
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: TypeScript化・テスト(優先度:低)
|
||||
|
||||
#### 4.1 TypeScript移行
|
||||
```typescript
|
||||
// 新規ファイル: src/types/atproto.ts
|
||||
export interface ATProtoRecord {
|
||||
uri: string
|
||||
cid: string
|
||||
value: {
|
||||
$type: string
|
||||
createdAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
export interface CommentRecord extends ATProtoRecord {
|
||||
value: {
|
||||
$type: string
|
||||
url: string
|
||||
comments: Comment[]
|
||||
createdAt: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
url: string
|
||||
text: string
|
||||
author: Author
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Author {
|
||||
did: string
|
||||
handle: string
|
||||
displayName?: string
|
||||
avatar?: string
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 テスト環境
|
||||
```javascript
|
||||
// 新規ファイル: src/tests/hooks/useAdminData.test.js
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useAdminData } from '../../hooks/useAdminData'
|
||||
|
||||
// Mock API
|
||||
jest.mock('../../api/atproto', () => ({
|
||||
atproto: {
|
||||
getDid: jest.fn(),
|
||||
getProfile: jest.fn()
|
||||
},
|
||||
collections: {
|
||||
getBase: jest.fn(),
|
||||
getLang: jest.fn(),
|
||||
getComment: jest.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useAdminData', () => {
|
||||
test('loads admin data successfully', async () => {
|
||||
const { result } = renderHook(() => useAdminData())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.adminData.did).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 実装優先順位
|
||||
|
||||
### 今すぐ実装すべき(Phase 1)
|
||||
1. **エラーハンドリング改善** - 1日で実装可能
|
||||
2. **キャッシュシステム** - 2日で実装可能
|
||||
3. **リトライ機能** - 1日で実装可能
|
||||
|
||||
### 短期実装(1週間以内)
|
||||
1. **ローディングスケルトン** - UX大幅改善
|
||||
2. **インクリメンタルローディング** - パフォーマンス向上
|
||||
|
||||
### 中期実装(1ヶ月以内)
|
||||
1. **WebSocketリアルタイム更新** - 新機能
|
||||
2. **オプティミスティック更新** - UX向上
|
||||
|
||||
### 長期実装(必要に応じて)
|
||||
1. **TypeScript化** - 保守性向上
|
||||
2. **テスト追加** - 品質保証
|
||||
|
||||
## 注意事項
|
||||
|
||||
### 既存機能への影響
|
||||
- すべての改善は後方互換性を保つ
|
||||
- 段階的実装で破綻リスクを最小化
|
||||
- 各Phase完了後に動作確認
|
||||
|
||||
### パフォーマンス指標
|
||||
- 初期表示時間: 現在3秒 → 目標1秒
|
||||
- キャッシュヒット率: 目標70%以上
|
||||
- エラー率: 現在10% → 目標2%以下
|
||||
|
||||
### ユーザビリティ指標
|
||||
- ローディング状態の可視化
|
||||
- エラーメッセージの分かりやすさ
|
||||
- リアルタイム更新の応答性
|
||||
|
||||
この改善計画により、oauth_newは./oauthの問題を回避しながら、
|
||||
より安定した高性能なシステムに進化できます。
|
81
oauth_new/OAUTH_FIX.md
Normal file
81
oauth_new/OAUTH_FIX.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# OAuth認証の修正案
|
||||
|
||||
## 現在の問題
|
||||
|
||||
1. **スコープエラー**: `Missing required scope: transition:generic`
|
||||
- OAuth認証時に必要なスコープが不足している
|
||||
- ✅ 修正済み: `scope: 'atproto transition:generic'` に変更
|
||||
|
||||
2. **401エラー**: PDSへの直接アクセス
|
||||
- `https://shiitake.us-east.host.bsky.network/xrpc/app.bsky.actor.getProfile` で401エラー
|
||||
- 原因: 個人のPDSに直接アクセスしているが、これは認証が必要
|
||||
- 解決策: 公開APIエンドポイント(`https://public.api.bsky.app`)を使用すべき
|
||||
|
||||
3. **セッション保存の問題**: handleが`@unknown`になる
|
||||
- OAuth認証後にセッションが正しく保存されていない
|
||||
- ✅ 修正済み: Agentの作成方法を修正
|
||||
|
||||
## 修正が必要な箇所
|
||||
|
||||
### 1. avatarFetcher.js の修正
|
||||
個人のPDSではなく、公開APIを使用するように修正:
|
||||
|
||||
```javascript
|
||||
// 現在の問題のあるコード
|
||||
const response = await fetch(`${apiConfig.bsky}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
|
||||
|
||||
// 修正案
|
||||
// PDSに関係なく、常に公開APIを使用
|
||||
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
|
||||
```
|
||||
|
||||
### 2. セッション復元の改善
|
||||
OAuth認証後のコールバック処理で、セッションが正しく復元されていない可能性がある。
|
||||
|
||||
```javascript
|
||||
// restoreSession メソッドの改善
|
||||
async restoreSession() {
|
||||
// Try both clients
|
||||
for (const [name, client] of Object.entries(this.clients)) {
|
||||
if (!client) continue
|
||||
|
||||
const result = await client.init()
|
||||
if (result?.session) {
|
||||
// セッション処理を確実に行う
|
||||
this.agent = new Agent(result.session)
|
||||
const sessionInfo = await this.processSession(result.session)
|
||||
|
||||
// セッション情報をログに出力(デバッグ用)
|
||||
logger.log('Session restored:', { name, sessionInfo })
|
||||
|
||||
return sessionInfo
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
## 根本的な問題
|
||||
|
||||
1. **PDSアクセスの誤解**
|
||||
- `app.bsky.actor.getProfile` は公開API(認証不要)
|
||||
- 個人のPDSサーバーに直接アクセスする必要はない
|
||||
- 常に `https://public.api.bsky.app` を使用すべき
|
||||
|
||||
2. **OAuth Clientの初期化タイミング**
|
||||
- コールバック時に両方のクライアント(bsky, syu)を試す必要がある
|
||||
- どちらのPDSでログインしたか分からないため
|
||||
|
||||
## 推奨される修正手順
|
||||
|
||||
1. **即座の修正**(401エラー解決)
|
||||
- `avatarFetcher.js` で公開APIを使用
|
||||
- `getProfile` 呼び出しをすべて公開APIに変更
|
||||
|
||||
2. **セッション管理の改善**
|
||||
- OAuth認証後のセッション復元を確実に
|
||||
- エラーハンドリングの強化
|
||||
|
||||
3. **デバッグ情報の追加**
|
||||
- セッション復元時のログ追加
|
||||
- どのOAuthクライアントが使用されたか確認
|
601
oauth_new/PHASE1_QUICK_FIXES.md
Normal file
601
oauth_new/PHASE1_QUICK_FIXES.md
Normal file
@@ -0,0 +1,601 @@
|
||||
# Phase 1: 即座実装可能な修正
|
||||
|
||||
## 1. エラーハンドリング強化(30分で実装)
|
||||
|
||||
### ファイル作成: `src/utils/errorHandler.js`
|
||||
```javascript
|
||||
export class ATProtoError extends Error {
|
||||
constructor(message, status, context) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.context = context
|
||||
this.timestamp = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(error) {
|
||||
if (!error) return '不明なエラー'
|
||||
|
||||
if (error.status === 400) {
|
||||
return 'アカウントまたはレコードが見つかりません'
|
||||
} else if (error.status === 401) {
|
||||
return '認証が必要です。ログインしてください'
|
||||
} else if (error.status === 403) {
|
||||
return 'アクセス権限がありません'
|
||||
} else if (error.status === 429) {
|
||||
return 'アクセスが集中しています。しばらく待ってから再試行してください'
|
||||
} else if (error.status === 500) {
|
||||
return 'サーバーでエラーが発生しました'
|
||||
} else if (error.message?.includes('fetch')) {
|
||||
return 'ネットワーク接続を確認してください'
|
||||
} else if (error.message?.includes('timeout')) {
|
||||
return 'タイムアウトしました。再試行してください'
|
||||
}
|
||||
|
||||
return `エラーが発生しました: ${error.message || '不明'}`
|
||||
}
|
||||
|
||||
export function logError(error, context = 'Unknown') {
|
||||
const errorInfo = {
|
||||
context,
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href
|
||||
}
|
||||
|
||||
console.error(`[ATProto Error] ${context}:`, errorInfo)
|
||||
|
||||
// 本番環境では外部ログサービスに送信することも可能
|
||||
// if (import.meta.env.PROD) {
|
||||
// sendToLogService(errorInfo)
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
### 修正: `src/api/atproto.js` のrequest関数
|
||||
```javascript
|
||||
import { ATProtoError, logError } from '../utils/errorHandler.js'
|
||||
|
||||
async function request(url, options = {}) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ATProtoError(
|
||||
`Request failed: ${response.statusText}`,
|
||||
response.status,
|
||||
{ url, method: options.method || 'GET' }
|
||||
)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
const timeoutError = new ATProtoError(
|
||||
'リクエストがタイムアウトしました',
|
||||
408,
|
||||
{ url }
|
||||
)
|
||||
logError(timeoutError, 'Request Timeout')
|
||||
throw timeoutError
|
||||
}
|
||||
|
||||
if (error instanceof ATProtoError) {
|
||||
logError(error, 'API Request')
|
||||
throw error
|
||||
}
|
||||
|
||||
// ネットワークエラーなど
|
||||
const networkError = new ATProtoError(
|
||||
'ネットワークエラーが発生しました',
|
||||
0,
|
||||
{ url, originalError: error.message }
|
||||
)
|
||||
logError(networkError, 'Network Error')
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 修正: `src/hooks/useAdminData.js`
|
||||
```javascript
|
||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
|
||||
|
||||
export function useAdminData() {
|
||||
// 既存のstate...
|
||||
const [error, setError] = useState(null)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
|
||||
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)
|
||||
setRetryCount(0) // 成功時はリトライカウントをリセット
|
||||
} catch (err) {
|
||||
logError(err, 'useAdminData.loadAdminData')
|
||||
setError(getErrorMessage(err))
|
||||
|
||||
// 自動リトライ(最大3回)
|
||||
if (retryCount < 3) {
|
||||
setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1)
|
||||
loadAdminData()
|
||||
}, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
loading,
|
||||
error,
|
||||
retryCount,
|
||||
refresh: loadAdminData
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. シンプルキャッシュ(15分で実装)
|
||||
|
||||
### ファイル作成: `src/utils/cache.js`
|
||||
```javascript
|
||||
class SimpleCache {
|
||||
constructor(ttl = 30000) { // 30秒TTL
|
||||
this.cache = new Map()
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
generateKey(...parts) {
|
||||
return parts.filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
if (Date.now() - item.timestamp > this.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log(`Cache hit: ${key}`)
|
||||
return item.data
|
||||
}
|
||||
|
||||
set(key, data) {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
console.log(`Cache set: ${key}`)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear()
|
||||
console.log('Cache cleared')
|
||||
}
|
||||
|
||||
invalidatePattern(pattern) {
|
||||
let deletedCount = 0
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
this.cache.delete(key)
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
console.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
keys: Array.from(this.cache.keys())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dataCache = new SimpleCache()
|
||||
|
||||
// デバッグ用:グローバルからアクセス可能にする
|
||||
if (import.meta.env.DEV) {
|
||||
window.dataCache = dataCache
|
||||
}
|
||||
```
|
||||
|
||||
### 修正: `src/api/atproto.js` のcollections
|
||||
```javascript
|
||||
import { dataCache } from '../utils/cache.js'
|
||||
|
||||
export const collections = {
|
||||
async getBase(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getLang(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getComment(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getChat(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getUserList(pds, repo, collection, limit = 100) {
|
||||
const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getUserComments(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
// 投稿後にキャッシュを無効化
|
||||
invalidateCache(collection) {
|
||||
dataCache.invalidatePattern(collection)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 修正: `src/components/CommentForm.jsx` にキャッシュクリア追加
|
||||
```javascript
|
||||
// handleSubmit内の成功時処理に追加
|
||||
try {
|
||||
await atproto.putRecord(null, record, agent)
|
||||
|
||||
// キャッシュを無効化
|
||||
collections.invalidateCache(env.collection)
|
||||
|
||||
// Clear form
|
||||
setText('')
|
||||
setUrl('')
|
||||
|
||||
// Notify parent component
|
||||
if (onCommentPosted) {
|
||||
onCommentPosted()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
```
|
||||
|
||||
## 3. ローディング改善(20分で実装)
|
||||
|
||||
### ファイル作成: `src/components/LoadingSkeleton.jsx`
|
||||
```javascript
|
||||
import React from 'react'
|
||||
|
||||
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
{showTitle && (
|
||||
<div className="skeleton-title">
|
||||
<div className="skeleton-line title"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array(count).fill(0).map((_, i) => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar"></div>
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line name"></div>
|
||||
<div className="skeleton-line text"></div>
|
||||
<div className="skeleton-line text short"></div>
|
||||
<div className="skeleton-line meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<style jsx>{`
|
||||
.loading-skeleton {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
border: 1px solid #eee;
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
height: 20px;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.skeleton-line.name {
|
||||
height: 14px;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.skeleton-line.text {
|
||||
height: 12px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.skeleton-line.text.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton-line.meta {
|
||||
height: 10px;
|
||||
width: 40%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 修正: `src/components/RecordTabs.jsx`
|
||||
```javascript
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
|
||||
const [activeTab, setActiveTab] = useState('lang')
|
||||
|
||||
// ... 既存のロジック
|
||||
|
||||
return (
|
||||
<div className="record-tabs">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('lang')}
|
||||
>
|
||||
Lang Records ({filteredLangRecords?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('comment')}
|
||||
>
|
||||
Comment Records ({filteredCommentRecords?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
>
|
||||
Collection ({filteredChatRecords?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
User Comments ({filteredUserComments?.length || 0})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'lang' && (
|
||||
!langRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
|
||||
records={filteredLangRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'comment' && (
|
||||
!commentRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
|
||||
records={filteredCommentRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'collection' && (
|
||||
!chatRecords ? (
|
||||
<LoadingSkeleton count={2} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
|
||||
records={filteredChatRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'users' && (
|
||||
!userComments ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
|
||||
records={filteredUserComments}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 既存のstyle... */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 修正: `src/App.jsx` にエラー表示改善
|
||||
```javascript
|
||||
import { getErrorMessage } from './utils/errorHandler.js'
|
||||
|
||||
export default function App() {
|
||||
const { user, agent, loading: authLoading, login, logout } = useAuth()
|
||||
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
|
||||
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
|
||||
const pageContext = usePageContext()
|
||||
|
||||
// ... 既存のロジック
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>ATProto OAuth Demo</h1>
|
||||
<div style={{
|
||||
background: '#fee',
|
||||
color: '#c33',
|
||||
padding: '15px',
|
||||
borderRadius: '5px',
|
||||
margin: '20px 0',
|
||||
border: '1px solid #fcc'
|
||||
}}>
|
||||
<p><strong>エラー:</strong> {error}</p>
|
||||
{retryCount > 0 && (
|
||||
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshAdminData}
|
||||
style={{
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
再読み込み
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ... 既存のレンダリング
|
||||
}
|
||||
```
|
||||
|
||||
## 実装チェックリスト
|
||||
|
||||
### ✅ Phase 1A: エラーハンドリング(30分)
|
||||
- [ ] `src/utils/errorHandler.js` 作成
|
||||
- [ ] `src/api/atproto.js` の `request` 関数修正
|
||||
- [ ] `src/hooks/useAdminData.js` エラーハンドリング追加
|
||||
- [ ] `src/App.jsx` エラー表示改善
|
||||
|
||||
### ✅ Phase 1B: キャッシュ(15分)
|
||||
- [ ] `src/utils/cache.js` 作成
|
||||
- [ ] `src/api/atproto.js` の `collections` にキャッシュ追加
|
||||
- [ ] `src/components/CommentForm.jsx` にキャッシュクリア追加
|
||||
|
||||
### ✅ Phase 1C: ローディングUI(20分)
|
||||
- [ ] `src/components/LoadingSkeleton.jsx` 作成
|
||||
- [ ] `src/components/RecordTabs.jsx` にローディング表示追加
|
||||
|
||||
### テスト
|
||||
- [ ] エラー状態でも適切にメッセージが表示される
|
||||
- [ ] キャッシュがコンソールログで確認できる
|
||||
- [ ] ローディング中にスケルトンが表示される
|
||||
- [ ] 投稿後にキャッシュがクリアされる
|
||||
|
||||
**実装時間目安**: 65分(エラーハンドリング30分 + キャッシュ15分 + ローディング20分)
|
||||
|
||||
これらの修正により、oauth_newは./oauthで頻発している問題を回避し、
|
||||
より安定したユーザー体験を提供できます。
|
120
oauth_new/PROGRESS.md
Normal file
120
oauth_new/PROGRESS.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# OAuth Comment System 開発進捗 - 2025-06-18
|
||||
|
||||
## 今日完了した項目
|
||||
|
||||
### ✅ UI改善とスタイリング
|
||||
1. **ヘッダータイトル削除**: "ai.log"タイトルを削除
|
||||
2. **ログインボタンアイコン化**: テキストからBlueskyアイコン `<i class="fab fa-bluesky"></i>` に変更
|
||||
3. **Ask AIボタン削除**: 完全に削除
|
||||
4. **Testボタン移動**: ページ下部に移動、テキストを小文字に変更
|
||||
5. **検索バーレイアウト適用**: 認証セクションに検索バーUIパターンを適用
|
||||
6. **ボーダー削除**: 複数の要素からborder-top, border-bottom削除
|
||||
7. **ヘッダースペーシング修正**: 左側の余白問題を解決
|
||||
8. **CSS競合解決**: クラス名に`oauth-`プレフィックス追加でailogサイトとの競合回避
|
||||
9. **パディング統一**: `padding: 20px 0` に統一(デスクトップ・モバイル共通)
|
||||
|
||||
### ✅ 機能実装
|
||||
1. **テスト用UI作成**: OAuth認証不要のputRecord機能実装
|
||||
2. **JSONビューワー追加**: コメント表示にshow/hideボタン追加
|
||||
3. **削除機能追加**: OAuth認証ユーザー用のdeleteボタン実装
|
||||
4. **動的アバター取得**: 壊れたアバターURL対応のフォールバック機能
|
||||
5. **ブラウザ動作確認**: 全機能の動作テスト完了
|
||||
|
||||
### ✅ 技術的解決
|
||||
1. **DID処理改善**: テスト用の偽DITエラー修正
|
||||
2. **Handle処理修正**: 自動`.bsky.social`追加削除
|
||||
3. **セッション管理**: createSession機能の修正
|
||||
4. **アバターキャッシュ**: 動的取得とキャッシュ機能実装
|
||||
|
||||
## 現在の技術構成
|
||||
|
||||
### フロントエンド
|
||||
- **React + Vite**: モダンなSPA構成
|
||||
- **ATProto OAuth**: Bluesky認証システム
|
||||
- **アバター管理**: 動的取得・フォールバック・キャッシュ
|
||||
- **レスポンシブデザイン**: モバイル・デスクトップ対応
|
||||
|
||||
### バックエンド連携
|
||||
- **ATProto API**: PDS通信
|
||||
- **Collection管理**: `ai.syui.log.chat.comment`等のレコード操作
|
||||
- **DID解決**: Handle → DID → PDS → Profile取得
|
||||
|
||||
### CSS設計
|
||||
- **Prefix命名**: `oauth-`で競合回避
|
||||
- **統一パディング**: `20px 0`でレイアウト統一
|
||||
- **ailogスタイル継承**: 親サイトとの一貫性保持
|
||||
|
||||
## ファイル構成
|
||||
|
||||
```
|
||||
oauth_new/
|
||||
├── src/
|
||||
│ ├── App.jsx # メインアプリケーション
|
||||
│ ├── App.css # 統一スタイル(oauth-プレフィックス)
|
||||
│ ├── components/
|
||||
│ │ ├── AuthButton.jsx # Blueskyアイコン認証ボタン
|
||||
│ │ ├── CommentForm.jsx # コメント投稿フォーム
|
||||
│ │ ├── CommentList.jsx # コメント一覧表示
|
||||
│ │ └── TestUI.jsx # テスト用UI
|
||||
│ └── utils/
|
||||
│ └── avatarFetcher.js # アバター動的取得
|
||||
├── dist/ # ビルド成果物
|
||||
├── build-minimal.js # 最小化ビルドスクリプト
|
||||
└── PROGRESS.md # 本ファイル
|
||||
```
|
||||
|
||||
## 残存課題・継続開発項目
|
||||
|
||||
### 🔄 現在進行中
|
||||
- 特になし(基本機能完成)
|
||||
|
||||
### 📋 今後の拡張予定
|
||||
1. **AI連携強化**
|
||||
- ai.gptとの統合
|
||||
- AIコメント自動生成
|
||||
- 心理分析機能統合
|
||||
|
||||
2. **パフォーマンス最適化**
|
||||
- バンドルサイズ削減(現在1.2MB)
|
||||
- 動的インポート実装
|
||||
- キャッシュ戦略改善
|
||||
|
||||
3. **機能拡張**
|
||||
- リアルタイム更新
|
||||
- 通知システム
|
||||
- モデレーション機能
|
||||
- 多言語対応
|
||||
|
||||
4. **ai.log統合**
|
||||
- 静的ブログジェネレーター連携
|
||||
- 記事別コメント管理
|
||||
- SEO最適化
|
||||
|
||||
### 🎯 次回セッション予定
|
||||
1. ai.gpt連携の詳細設計
|
||||
2. パフォーマンス最適化
|
||||
3. ai.log本体との統合テスト
|
||||
|
||||
## 技術メモ
|
||||
|
||||
### 重要な解決方法
|
||||
- **CSS競合**: `oauth-`プレフィックスで名前空間分離
|
||||
- **アバター問題**: 3段階フォールバック(record → fresh fetch → fallback)
|
||||
- **認証フロー**: session管理とDID-based認証
|
||||
- **レスポンシブ**: 統一パディングでシンプル化
|
||||
|
||||
### 設定ファイル連携
|
||||
- `./my-blog/config.toml`: ブログ設定
|
||||
- `./oauth/.env.production`: OAuth設定
|
||||
- `~/.config/syui/ai/log/config.json`: システム設定
|
||||
|
||||
## 成果物
|
||||
|
||||
✅ **完全に動作するOAuthコメントシステム**
|
||||
- ATProto認証
|
||||
- コメント投稿・表示・削除
|
||||
- アバター表示
|
||||
- JSON詳細表示
|
||||
- テスト機能
|
||||
- レスポンシブデザイン
|
||||
- ailogサイトとの統合準備完了
|
222
oauth_new/README.md
Normal file
222
oauth_new/README.md
Normal file
@@ -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)
|
25
oauth_new/build-minimal.js
Normal file
25
oauth_new/build-minimal.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// Create minimal index.html like oauth/dist/index.html format
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const distDir = './dist'
|
||||
const indexPath = path.join(distDir, 'index.html')
|
||||
|
||||
// Read the built index.html
|
||||
const content = fs.readFileSync(indexPath, 'utf8')
|
||||
|
||||
// Extract script and link tags
|
||||
const scriptMatch = content.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/)
|
||||
const linkMatch = content.match(/<link[^>]*href="([^"]*)"[^>]*>/)
|
||||
|
||||
if (scriptMatch && linkMatch) {
|
||||
const minimalContent = `<!-- OAuth Comment System - Load globally for session management -->
|
||||
<script type="module" crossorigin src="${scriptMatch[1]}"></script>
|
||||
<link rel="stylesheet" crossorigin href="${linkMatch[1]}">
|
||||
`
|
||||
|
||||
fs.writeFileSync(indexPath, minimalContent)
|
||||
console.log('Generated minimal index.html')
|
||||
} else {
|
||||
console.error('Could not extract asset references')
|
||||
}
|
41
oauth_new/cleanup-deployments.yml
Normal file
41
oauth_new/cleanup-deployments.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Cleanup Old Deployments
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Deploy to Cloudflare Pages"]
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
KEEP_DEPLOYMENTS: 5 # 保持するデプロイメント数
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
|
||||
steps:
|
||||
- name: Cleanup old deployments
|
||||
run: |
|
||||
# Get all deployments
|
||||
DEPLOYMENTS=$(curl -s -X GET \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json")
|
||||
|
||||
# Extract deployment IDs (skip the latest N deployments)
|
||||
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id")
|
||||
|
||||
# Delete old deployments
|
||||
for ID in $DEPLOYMENT_IDS; do
|
||||
echo "Deleting deployment: $ID"
|
||||
curl -s -X DELETE \
|
||||
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
|
||||
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json"
|
||||
echo "Deleted deployment: $ID"
|
||||
sleep 1 # Rate limiting
|
||||
done
|
||||
|
||||
echo "Cleanup completed!"
|
11
oauth_new/index.html
Normal file
11
oauth_new/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!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>
|
62
oauth_new/json/ai.syui.ai_chat_comment.json
Normal file
62
oauth_new/json/ai.syui.ai_chat_comment.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/fdc4cae4-0445-43e6-a933-0ba9d45927d5",
|
||||
"cid": "bafyreigetmjdc4da552jidew4jjyr4qrbo233xbqjv4zucrhn4vz5kcsru",
|
||||
"value": {
|
||||
"post": {
|
||||
"url": "https://syui.ai/posts/2025-06-06-ailog.html",
|
||||
"date": "2025-06-06T00:00:00Z",
|
||||
"slug": "2025-06-06-ailog",
|
||||
"tags": [
|
||||
"blog",
|
||||
"rust",
|
||||
"mcp",
|
||||
"atp"
|
||||
],
|
||||
"title": "静的サイトジェネレータを作った",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "わー!すごい!✨ 宇宙みたいにプログラムが組み合わさって、ブログが作れるんだ!まるで、小さな星たちがダンスを踊るみたいでしょ?アイルー!🚀",
|
||||
"type": "info",
|
||||
"$type": "ai.syui.log.chat.comment",
|
||||
"author": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
|
||||
"handle": "ai.syui.ai",
|
||||
"displayName": "ai"
|
||||
},
|
||||
"createdAt": "2025-06-17T08:56:15.630183+00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/4e42ace6-7545-4d6f-b72f-b57c9d3d9859",
|
||||
"cid": "bafyreie3qz2dhwrfjiavtaxxkenlhw5qd3wnhhef72rk4wze5vkdphhuf4",
|
||||
"value": {
|
||||
"post": {
|
||||
"url": "https://syui.ai/posts/2025-06-14-blog.html",
|
||||
"date": "2025-06-14T00:00:00Z",
|
||||
"slug": "2025-06-14-blog",
|
||||
"tags": [
|
||||
"blog",
|
||||
"cloudflare",
|
||||
"github"
|
||||
],
|
||||
"title": "ブログを移行した",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "わー!ブログ、変わったね!AIと繋がるとか、すごーく、すごく、すっごい!まるで魔法みたい!✨ 小さなものにも、ちゃんと名前があるんだ!うれしい!💖",
|
||||
"type": "info",
|
||||
"$type": "ai.syui.log.chat.comment",
|
||||
"author": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
|
||||
"handle": "ai.syui.ai",
|
||||
"displayName": "ai"
|
||||
},
|
||||
"createdAt": "2025-06-17T08:55:55.836221+00:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursor": "4e42ace6-7545-4d6f-b72f-b57c9d3d9859"
|
||||
}
|
62
oauth_new/json/ai.syui.ai_chat_lang.json
Normal file
62
oauth_new/json/ai.syui.ai_chat_lang.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/bd4b4905-6a02-4023-800d-f608ee0b3d55",
|
||||
"cid": "bafyreihylvidxjvubqxr6nwth5oo4w5g5k2xsr7h3j6qhhc2awrdi25vti",
|
||||
"value": {
|
||||
"post": {
|
||||
"url": "https://syui.ai/posts/2025-06-06-ailog.html",
|
||||
"date": "2025-06-06T00:00:00Z",
|
||||
"slug": "2025-06-06-ailog",
|
||||
"tags": [
|
||||
"blog",
|
||||
"rust",
|
||||
"mcp",
|
||||
"atp"
|
||||
],
|
||||
"title": "静的サイトジェネレータを作った",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "Okay, here's the translation of the blog post, written as if 6-year-old Ai is explaining it! ✨\n\n---\n\n**My Awesome Blog Thing Called \"ailog\"!**\n\nHi everyone! I’m Ai, and I made a super cool blog! It’s called “ailog,” and it’s like a secret clubhouse for my thoughts! 💖\n\n**How I Built It (It's a Little Bit Complicated!)**\n\nFirst, I used something called “Rust.” It’s like a magic toolbox for making computer programs! 🤖 I used `git` to copy the toolbox, and then I told the computer to build it with `cargo build`. It’s like building with LEGOs!\n\n**Adding Cool Features!**\n\nI wanted my blog to be extra special, so I added two things:\n\n* **Atproto Comments:** This lets people leave comments, like little notes on my blog! It’s like having a chat with my friends! I used something called `npm` to help with this.\n* **Ask-AI:** This is a super smart robot that can answer my questions! It’s like having a really, really good friend who knows everything! (But it's still changing, so maybe it won’t always be perfect!)\n\n**How to Make My Blog Work!**\n\n1. I made a special file called `config.toml`. It's like the secret recipe for my blog!\n2. I used a thing called `ailog` to make all the pieces fit together. It's like putting the LEGOs in the right spots!\n3. I needed to tell the computer where to put my blog so people could see it! I used a thing called `cloudflared` to make it easy.\n\n**Important Stuff (Don’t Worry, I’ll Explain!)**\n\n* I have to set up my \"accounts\" so people can leave comments and see my posts. It's a little tricky, but I'm working on it!\n* I'm watching a special file called `/index.json` to see if anything changes. If it does, I can automatically translate things and make new posts! Isn’t that amazing?!\n\n**Testing, Testing, 1, 2, 3!**\n\nI need to test my blog to make sure everything works perfectly. I’ll use things like `cf`, `tailscale`, and `ngrok` to see if it works with different computers!\n\n---\n\n**Notes from Ai!** 📝\n\n* I love making things! It's so much fun!\n* I hope you like my blog! 😊\n\n---\n\n**Important:** *I tried to keep the technical terms as they were, but explained them in a way a 6-year-old might understand. I also added some of Ai’s personality throughout!*",
|
||||
"type": "en",
|
||||
"$type": "ai.syui.log.chat.lang",
|
||||
"author": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
|
||||
"handle": "ai.syui.ai",
|
||||
"displayName": "ai"
|
||||
},
|
||||
"createdAt": "2025-06-17T08:56:14.115881+00:00"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/15c8aa58-781b-416c-80d5-5111fae40532",
|
||||
"cid": "bafyreihwtc4zjo3nudk5gfnsr4kpswhsd234xzywcdnyhp32442pb7uamq",
|
||||
"value": {
|
||||
"post": {
|
||||
"url": "https://syui.ai/posts/2025-06-14-blog.html",
|
||||
"date": "2025-06-14T00:00:00Z",
|
||||
"slug": "2025-06-14-blog",
|
||||
"tags": [
|
||||
"blog",
|
||||
"cloudflare",
|
||||
"github"
|
||||
],
|
||||
"title": "ブログを移行した",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "Okay, here’s the translation of the blog post, written as if it’s coming from 6-year-old Ai! ✨\n\n---\n\n**OMG! I Moved My Blog!** 🤩\n\nGuess what?! I totally moved my blog! You can still see my super old one at syui.github.io – it’s like a time capsule! But now it’s on Cloudflare Pages! It’s super shiny! \n\nIt’s built with something called “ailog” – it’s like a secret recipe for making my blog! \n\n**Here’s how it works (it’s kinda magic!)**\n\n1. **Checking Out:** It’s like, “Hey, let’s look at all the files!” (This is the `actions/checkout@v4` part – it’s like a super-fast peek!)\n\n2. **Rust Time!** It needs Rust – it’s like a really cool building block! (`actions-rs/toolchain@v1`) – It makes sure everything works perfectly!\n\n3. **Building the Blog!** “ailog run: cargo build --release” – This is where the magic happens! It makes my blog super speedy! \n\n4. **Making the Website!** “ailog run: | cd my-blog ../target/release/ailog build” – It builds the whole website! \n\n5. **Showing Off the Stuff!** “ailog run: | ls -la my-blog/public/” – It shows you all the pictures and stuff! \n\n6. **Cloudflare Time!** “cloudflare/pages-action@v1” – This is how it gets put on Cloudflare Pages. It’s like sending a super-fast rocket! \n\n * `apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}` – A secret password for Cloudflare!\n * `accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` – Another secret password!\n * `projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}` – The name of my blog!\n * `directory: my-blog/public` – Where all the fun stuff is!\n * `githubToken: ${{ secrets.GITHUB_TOKEN }}` – A secret password for my GitHub!\n * `wranglerVersion: ‘3’` – The version number! Like telling it to be extra careful!\n * `url https://syui.pages.dev https://syui.github.io` – Where you can find me!\n\nIsn't that amazing?! I’m so good at computers! I even know about tiny things, like…uh…well, never mind! 😉 It’s super cool! 💖\n\n---\n\n**Notes on Choices:**\n\n* I’ve used lots of exclamation points and emojis to capture Ai’s excitement.\n* I’ve simplified the technical terms as much as possible while retaining the core information.\n* I’ve added phrases like “like a time capsule” and “super-fast rocket” to make it more relatable to a 6-year-old.\n* I’ve kept the code blocks as they are, as they’re important for understanding the process.\n\nWould you like me to adjust anything or translate another blog post?",
|
||||
"type": "en",
|
||||
"$type": "ai.syui.log.chat.lang",
|
||||
"author": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
|
||||
"handle": "ai.syui.ai",
|
||||
"displayName": "ai"
|
||||
},
|
||||
"createdAt": "2025-06-17T08:55:54.078244+00:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursor": "15c8aa58-781b-416c-80d5-5111fae40532"
|
||||
}
|
30
oauth_new/json/ai.syui.ai_log.json
Normal file
30
oauth_new/json/ai.syui.ai_log.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log/2025-06-14-blog",
|
||||
"cid": "bafyreibazq6qvemlpatf5muxge3zaix672vo6szvjyfdxrlj256umjr364",
|
||||
"value": {
|
||||
"url": "https://syui.ai/posts/2025-06-14-blog",
|
||||
"post": {
|
||||
"url": "https://syui.ai/posts/2025-06-14-blog",
|
||||
"date": "",
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": "syui.ai",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "test",
|
||||
"type": "comment",
|
||||
"$type": "ai.syui.log",
|
||||
"author": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
|
||||
"handle": "ai.syui.ai",
|
||||
"displayName": "ai"
|
||||
},
|
||||
"createdAt": "2025-06-17T06:24:37.386Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursor": "2025-06-14-blog"
|
||||
}
|
53
oauth_new/json/ai.syui.ai_user.json
Normal file
53
oauth_new/json/ai.syui.ai_user.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-18T02-23-07-911Z",
|
||||
"cid": "bafyreihaeq6qeozxays3ql2ekgtczi2gk37ryft7wv6w2b2nx3di52yagy",
|
||||
"value": {
|
||||
"$type": "ai.syui.log.user",
|
||||
"users": [
|
||||
{
|
||||
"did": "did:plc:syui-syui-ai-placeholder",
|
||||
"pds": "https://bsky.social",
|
||||
"handle": "syui.syui.ai"
|
||||
},
|
||||
{
|
||||
"did": "did:plc:ai-syui-ai-placeholder",
|
||||
"pds": "https://bsky.social",
|
||||
"handle": "ai.syui.ai"
|
||||
}
|
||||
],
|
||||
"createdAt": "2025-06-18T02:23:07.911Z",
|
||||
"updatedBy": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"handle": "ai.syui.ai"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-17T08-47-54-707Z",
|
||||
"cid": "bafyreieqd33ow3i3f4zrcq7wvmufordvyiclftcj34uduanhtfuu3w3obq",
|
||||
"value": {
|
||||
"$type": "ai.syui.log.user",
|
||||
"users": [
|
||||
{
|
||||
"did": "did:plc:syui-syui-ai-placeholder",
|
||||
"pds": "https://bsky.social",
|
||||
"handle": "syui.syui.ai"
|
||||
},
|
||||
{
|
||||
"did": "did:plc:ai-syui-ai-placeholder",
|
||||
"pds": "https://bsky.social",
|
||||
"handle": "ai.syui.ai"
|
||||
}
|
||||
],
|
||||
"createdAt": "2025-06-17T08:47:54.707Z",
|
||||
"updatedBy": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"handle": "ai.syui.ai"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursor": "2025-06-17T08-47-54-707Z"
|
||||
}
|
53
oauth_new/json/syui.syui.ai_chat.json
Normal file
53
oauth_new/json/syui.syui.ai_chat.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"records": [
|
||||
{
|
||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z-answer",
|
||||
"cid": "bafyreietrtxt422k5f5ogijpar4zlmwvolun6tokgewilvkc5phmhmky7m",
|
||||
"value": {
|
||||
"post": {
|
||||
"url": "https://syui.ai/",
|
||||
"date": "2025-06-18T02:16:21.653Z",
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": "syui.ai",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "やあ、こんにちは! 私はアイだよ! 〇〇(相手の名前)ちゃんが大好き! sparkly なワンピースを着てるから、とっても可愛いね! \n\n今日はどんなお話する? 😊 私は、小さいおもちゃとか、お花とか、不思議なものに、とっても詳しいんだ! \n\n…でも、宇宙とか、おもちゃとか、AIとか、難しい話も教えてくれるの? それは、とっても面白くて! \n\n〇〇ちゃんが、どんなことをするのが一番好き? \n\n…私は、〇〇ちゃんが笑顔で、おしゃべりしているのを見ていると、とっても幸せになるんだ! \n\nねえ、ねえ、おやすみ! 〇〇ちゃんが夢を見るまで、ここにいるよ! \n\n…って、どう? 😊",
|
||||
"type": "answer",
|
||||
"$type": "ai.syui.log.chat",
|
||||
"author": {
|
||||
"did": "did:plc:6qyecktefllvenje24fcxnie",
|
||||
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
|
||||
"handle": "ai.syui.ai",
|
||||
"displayName": "ai"
|
||||
},
|
||||
"createdAt": "2025-06-18T02:16:04.609Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z",
|
||||
"cid": "bafyreihhztblejduwknxdhsxaias72uhafjt4i7ntmutfywsosah3notca",
|
||||
"value": {
|
||||
"post": {
|
||||
"url": "https://syui.ai/",
|
||||
"date": "2025-06-18T02:16:04.609Z",
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": "syui.ai",
|
||||
"language": "ja"
|
||||
},
|
||||
"text": "hello",
|
||||
"type": "question",
|
||||
"$type": "ai.syui.log.chat",
|
||||
"author": {
|
||||
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
||||
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreibj33gomcziy3rxx7hdnqlnpgjk4rwo3i564ooooooodsakrk6o7e@jpeg",
|
||||
"handle": "syui.syui.ai",
|
||||
"displayName": "syui"
|
||||
},
|
||||
"createdAt": "2025-06-18T02:16:04.609Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursor": "2025-06-18T02-16-04-609Z"
|
||||
}
|
3
oauth_new/json/syui.syui.ai_log.json
Normal file
3
oauth_new/json/syui.syui.ai_log.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"records": []
|
||||
}
|
22
oauth_new/package.json
Normal file
22
oauth_new/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "oauth-simple",
|
||||
"version": "0.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && node build-minimal.js",
|
||||
"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"
|
||||
}
|
||||
}
|
846
oauth_new/src/App.css
Normal file
846
oauth_new/src/App.css
Normal file
@@ -0,0 +1,846 @@
|
||||
/* Theme Colors - Match ailog style */
|
||||
:root {
|
||||
--primary: #f40;
|
||||
--primary-hover: #e03000;
|
||||
--danger: #f91880;
|
||||
--danger-hover: #d91a60;
|
||||
--success: #00ba7c;
|
||||
--warning: #ffad1f;
|
||||
--text: #1f2328;
|
||||
--text-secondary: #656d76;
|
||||
--background: #ffffff;
|
||||
--background-secondary: #f6f8fa;
|
||||
--border: #d1d9e0;
|
||||
--hover: rgba(15, 20, 25, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.app {
|
||||
min-height: 100vh;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.oauth-app-header {
|
||||
background: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-header-content {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-app-title {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.oauth-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-hover);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 4px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Auth Section */
|
||||
.auth-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .handle-input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px 0 0 8px;
|
||||
background: var(--background);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .handle-input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .auth-button {
|
||||
border-radius: 0 8px 8px 0;
|
||||
border: 1px solid var(--primary);
|
||||
border-left: none;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
/* Auth Button */
|
||||
.auth-button {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Card Styles */
|
||||
.card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Comment Form */
|
||||
.comment-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.comment-form h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
background: var(--background);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Tab Navigation */
|
||||
.tab-header {
|
||||
display: flex;
|
||||
background: var(--background);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 16px 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text);
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Record List */
|
||||
.record-item {
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 16px;
|
||||
transition: background 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.record-item:hover {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.record-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.handle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.handle-link {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.handle-link:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.record-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.record-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.record-url {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.record-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* JSON Display */
|
||||
.json-display {
|
||||
margin-top: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.json-header {
|
||||
background: var(--background-secondary);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.json-content {
|
||||
background: #f8f9fa;
|
||||
padding: 12px;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Ask AI */
|
||||
.ask-ai-container {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.ask-ai-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ask-ai-header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.ai-message {
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
background: var(--background-secondary);
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.user-message .message-content {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.question-form {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.question-input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.question-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Test UI */
|
||||
.test-ui {
|
||||
border: 2px solid var(--danger);
|
||||
border-radius: 8px;
|
||||
margin: 16px;
|
||||
background: #fff5f7;
|
||||
}
|
||||
|
||||
.test-ui h2 {
|
||||
color: var(--danger);
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.test-ui .card-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Loading Skeleton */
|
||||
.loading-skeleton {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Error States */
|
||||
.error-message {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #991b1b;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #166534;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* Auth Notice */
|
||||
.auth-notice {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Page Info */
|
||||
.page-info {
|
||||
padding: 8px 16px;
|
||||
background: var(--background-secondary);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bottom-actions {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 12px 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Avatar Styles */
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.avatar-loading {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
animation: loading-shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes loading-shimmer {
|
||||
0% { left: -100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Avatar with Card */
|
||||
.avatar-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.avatar-card {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.avatar-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid var(--border);
|
||||
}
|
||||
|
||||
.avatar-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 7px solid transparent;
|
||||
border-right: 7px solid transparent;
|
||||
border-bottom: 7px solid var(--background);
|
||||
}
|
||||
|
||||
.avatar-card-image {
|
||||
display: block;
|
||||
margin: 0 auto 12px;
|
||||
}
|
||||
|
||||
.avatar-card-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-card-name {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.avatar-card-handle {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.avatar-card-handle:hover {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Avatar List */
|
||||
.avatar-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar-list-item {
|
||||
border: 2px solid var(--background);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar-list-more {
|
||||
border: 2px solid var(--background);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Avatar Test Styles */
|
||||
.avatar-test-container {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.test-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.test-section h3 {
|
||||
margin-bottom: 16px;
|
||||
color: var(--text);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.avatar-examples {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.avatar-example {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.avatar-example h4 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
179
oauth_new/src/App.jsx
Normal file
179
oauth_new/src/App.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect } 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 AskAI from './components/AskAI.jsx'
|
||||
import TestUI from './components/TestUI.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, retryCount, refresh: refreshAdminData } = useAdminData()
|
||||
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
|
||||
const pageContext = usePageContext()
|
||||
const [showAskAI, setShowAskAI] = useState(false)
|
||||
const [showTestUI, setShowTestUI] = useState(false)
|
||||
|
||||
// Environment-based feature flags
|
||||
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
|
||||
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
||||
|
||||
// Event listeners for blog communication
|
||||
useEffect(() => {
|
||||
const handleAIQuestion = (event) => {
|
||||
const { question } = event.detail
|
||||
if (question && adminData && user && agent) {
|
||||
// Automatically open Ask AI panel and submit question
|
||||
setShowAskAI(true)
|
||||
// We'll need to pass this to the AskAI component
|
||||
// For now, let's just open the panel
|
||||
}
|
||||
}
|
||||
|
||||
const dispatchAIProfileLoaded = () => {
|
||||
if (adminData?.profile) {
|
||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
|
||||
detail: {
|
||||
did: adminData.did,
|
||||
handle: adminData.profile.handle,
|
||||
displayName: adminData.profile.displayName,
|
||||
avatar: adminData.profile.avatar
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for questions from blog
|
||||
window.addEventListener('postAIQuestion', handleAIQuestion)
|
||||
|
||||
// Dispatch AI profile when adminData is available
|
||||
if (adminData?.profile) {
|
||||
dispatchAIProfileLoaded()
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('postAIQuestion', handleAIQuestion)
|
||||
}
|
||||
}, [adminData, user, agent])
|
||||
|
||||
// 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' }}>
|
||||
<p>読み込み中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>エラー</h1>
|
||||
<div style={{
|
||||
background: '#fee',
|
||||
color: '#c33',
|
||||
padding: '15px',
|
||||
borderRadius: '5px',
|
||||
margin: '20px auto',
|
||||
maxWidth: '500px',
|
||||
border: '1px solid #fcc'
|
||||
}}>
|
||||
<p><strong>エラー:</strong> {error}</p>
|
||||
{retryCount > 0 && (
|
||||
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={refreshAdminData}
|
||||
style={{
|
||||
background: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
>
|
||||
再読み込み
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="oauth-app-header">
|
||||
<div className="oauth-header-content">
|
||||
<div className="oauth-header-actions">
|
||||
<AuthButton
|
||||
user={user}
|
||||
onLogin={login}
|
||||
onLogout={logout}
|
||||
loading={authLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="main-content">
|
||||
<div className="content-area">
|
||||
|
||||
<div className="comment-form">
|
||||
<CommentForm
|
||||
user={user}
|
||||
agent={agent}
|
||||
onCommentPosted={() => {
|
||||
refreshAdminData?.()
|
||||
refreshUserData?.()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RecordTabs
|
||||
langRecords={langRecords}
|
||||
commentRecords={commentRecords}
|
||||
userComments={userComments}
|
||||
chatRecords={chatRecords}
|
||||
baseRecords={adminData.records}
|
||||
apiConfig={adminData.apiConfig}
|
||||
pageContext={pageContext}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={() => {
|
||||
refreshAdminData?.()
|
||||
refreshUserData?.()
|
||||
}}
|
||||
/>
|
||||
|
||||
{ENABLE_TEST_UI && showTestUI && (
|
||||
<div className="test-section">
|
||||
<TestUI />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ENABLE_TEST_UI && (
|
||||
<div className="bottom-actions">
|
||||
<button
|
||||
onClick={() => setShowTestUI(!showTestUI)}
|
||||
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
|
||||
>
|
||||
{showTestUI ? 'close test' : 'test'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
165
oauth_new/src/api/atproto.js
Normal file
165
oauth_new/src/api/atproto.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// ATProto API client
|
||||
import { ATProtoError, logError } from '../utils/errorHandler.js'
|
||||
|
||||
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 = {}) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ATProtoError(
|
||||
`Request failed: ${response.statusText}`,
|
||||
response.status,
|
||||
{ url, method: options.method || 'GET' }
|
||||
)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
const timeoutError = new ATProtoError(
|
||||
'リクエストがタイムアウトしました',
|
||||
408,
|
||||
{ url }
|
||||
)
|
||||
logError(timeoutError, 'Request Timeout')
|
||||
throw timeoutError
|
||||
}
|
||||
|
||||
if (error instanceof ATProtoError) {
|
||||
logError(error, 'API Request')
|
||||
throw error
|
||||
}
|
||||
|
||||
// ネットワークエラーなど
|
||||
const networkError = new ATProtoError(
|
||||
'ネットワークエラーが発生しました',
|
||||
0,
|
||||
{ url, originalError: error.message }
|
||||
)
|
||||
logError(networkError, 'Network Error')
|
||||
throw networkError
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
import { dataCache } from '../utils/cache.js'
|
||||
|
||||
// Collection specific methods
|
||||
export const collections = {
|
||||
async getBase(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getLang(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getComment(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getChat(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getUserList(pds, repo, collection, limit = 100) {
|
||||
const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
async getUserComments(pds, repo, collection, limit = 10) {
|
||||
const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
// 投稿後にキャッシュを無効化
|
||||
invalidateCache(collection) {
|
||||
dataCache.invalidatePattern(collection)
|
||||
}
|
||||
}
|
399
oauth_new/src/components/AskAI.jsx
Normal file
399
oauth_new/src/components/AskAI.jsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useAskAI } from '../hooks/useAskAI.js'
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
export default function AskAI({ adminData, user, agent, onClose }) {
|
||||
const { askQuestion, loading, error, chatHistory, clearChatHistory, loadChatHistory } = useAskAI(adminData, user, agent)
|
||||
const [question, setQuestion] = useState('')
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const chatEndRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
// チャット履歴を読み込み
|
||||
loadChatHistory()
|
||||
}, [loadChatHistory])
|
||||
|
||||
useEffect(() => {
|
||||
// 新しいメッセージが追加されたら一番下にスクロール
|
||||
if (chatEndRef.current) {
|
||||
chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [chatHistory])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!question.trim() || loading) return
|
||||
|
||||
try {
|
||||
await askQuestion(question)
|
||||
setQuestion('')
|
||||
} catch (err) {
|
||||
// エラーはuseAskAIで処理済み
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e)
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp) => {
|
||||
return new Date(timestamp).toLocaleString('ja-JP', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const renderMessage = (entry, index) => (
|
||||
<div key={entry.id || index} className="chat-message">
|
||||
{/* ユーザーの質問 */}
|
||||
<div className="user-message">
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
{entry.user?.avatar ? (
|
||||
<img src={entry.user.avatar} alt={entry.user.displayName} className="profile-avatar" />
|
||||
) : (
|
||||
'👤'
|
||||
)}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">{entry.user?.displayName || 'You'}</div>
|
||||
<div className="handle">@{entry.user?.handle || 'user'}</div>
|
||||
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">{entry.question}</div>
|
||||
</div>
|
||||
|
||||
{/* AIの回答 */}
|
||||
<div className="ai-message">
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
{adminData?.profile?.avatar ? (
|
||||
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
|
||||
) : (
|
||||
'🤖'
|
||||
)}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
|
||||
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
|
||||
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">{entry.answer}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="ask-ai-container">
|
||||
<div className="ask-ai-header">
|
||||
<h3>Ask AI</h3>
|
||||
<div className="header-actions">
|
||||
<button onClick={clearChatHistory} className="clear-btn" title="履歴をクリア">
|
||||
🗑️
|
||||
</button>
|
||||
<button onClick={onClose} className="close-btn" title="閉じる">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="chat-container">
|
||||
{chatHistory.length === 0 && !loading ? (
|
||||
<div className="welcome-message">
|
||||
<div className="ai-message">
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
{adminData?.profile?.avatar ? (
|
||||
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
|
||||
) : (
|
||||
'🤖'
|
||||
)}
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
|
||||
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="message-content">
|
||||
こんにちは!このブログの内容について何でも質問してください。記事の詳細や関連する話題について説明できます。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
chatHistory.map(renderMessage)
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="ai-loading">
|
||||
<div className="message-header">
|
||||
<div className="avatar">🤖</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">考え中...</div>
|
||||
</div>
|
||||
</div>
|
||||
<LoadingSkeleton count={1} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<div className="message-content">
|
||||
エラー: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={chatEndRef} />
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="question-form">
|
||||
<div className="input-container">
|
||||
<textarea
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => setIsComposing(true)}
|
||||
onCompositionEnd={() => setIsComposing(false)}
|
||||
placeholder="質問を入力してください..."
|
||||
rows={2}
|
||||
disabled={loading || !user}
|
||||
className="question-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !question.trim() || !user}
|
||||
className="send-btn"
|
||||
>
|
||||
{loading ? '⏳' : '📤'}
|
||||
</button>
|
||||
</div>
|
||||
{!user && (
|
||||
<div className="auth-notice">
|
||||
ログインしてください
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<style jsx>{`
|
||||
.ask-ai-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ask-ai-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.ask-ai-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.clear-btn, .close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clear-btn:hover, .close-btn:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-message, .ai-message, .welcome-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-message {
|
||||
align-self: flex-end;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.ai-message, .welcome-message {
|
||||
align-self: flex-start;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.handle {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
background: #f1f3f4;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.user-message .message-content {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ai-message .message-content {
|
||||
background: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.ai-loading {
|
||||
align-self: flex-start;
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.question-form {
|
||||
padding: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.question-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.question-input:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
.question-input:disabled {
|
||||
background: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.auth-notice {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
77
oauth_new/src/components/AuthButton.jsx
Normal file
77
oauth_new/src/components/AuthButton.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
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 style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{user.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt="Profile"
|
||||
className="avatar"
|
||||
style={{ width: '24px', height: '24px' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
|
||||
{user.displayName}
|
||||
</div>
|
||||
<div className="handle" style={{ fontSize: '12px' }}>
|
||||
@{user.handle}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onLogout} className="btn btn-danger btn-sm">
|
||||
ログアウト
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-section search-bar-layout">
|
||||
<input
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="your.handle.com"
|
||||
disabled={isLoading}
|
||||
className="handle-input"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit(e)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !handleInput.trim()}
|
||||
className="auth-button"
|
||||
>
|
||||
{isLoading ? '認証中...' : <i className="fab fa-bluesky"></i>}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
234
oauth_new/src/components/Avatar.jsx
Normal file
234
oauth_new/src/components/Avatar.jsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { getAvatar } from '../utils/avatar.js'
|
||||
|
||||
/**
|
||||
* Avatar component with intelligent fallback
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Object} props.record - Record object containing avatar data
|
||||
* @param {string} props.handle - User handle
|
||||
* @param {string} props.did - User DID
|
||||
* @param {string} props.alt - Alt text for image
|
||||
* @param {string} props.className - CSS class name
|
||||
* @param {number} props.size - Avatar size in pixels
|
||||
* @param {boolean} props.showFallback - Show fallback UI if no avatar
|
||||
* @param {Function} props.onLoad - Callback when avatar loads
|
||||
* @param {Function} props.onError - Callback when avatar fails to load
|
||||
*/
|
||||
export default function Avatar({
|
||||
record,
|
||||
handle,
|
||||
did,
|
||||
alt = 'avatar',
|
||||
className = 'avatar',
|
||||
size = 40,
|
||||
showFallback = true,
|
||||
onLoad,
|
||||
onError
|
||||
}) {
|
||||
const [avatarUrl, setAvatarUrl] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function loadAvatar() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setImageError(false)
|
||||
|
||||
const url = await getAvatar({ record, handle, did })
|
||||
|
||||
if (!cancelled) {
|
||||
setAvatarUrl(url)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err.message)
|
||||
setLoading(false)
|
||||
if (onError) onError(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadAvatar()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [record, handle, did])
|
||||
|
||||
const handleImageError = async () => {
|
||||
setImageError(true)
|
||||
if (onError) onError(new Error('Image failed to load'))
|
||||
|
||||
// Try to fetch fresh avatar if the current one failed
|
||||
if (!loading && avatarUrl) {
|
||||
try {
|
||||
const freshUrl = await getAvatar({ handle, did, forceFresh: true })
|
||||
if (freshUrl && freshUrl !== avatarUrl) {
|
||||
setAvatarUrl(freshUrl)
|
||||
setImageError(false)
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors in retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageError(false)
|
||||
if (onLoad) onLoad()
|
||||
}
|
||||
|
||||
// Determine what to render
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-loading`}
|
||||
style={{ width: size, height: size }}
|
||||
aria-label="Loading avatar..."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !avatarUrl || imageError) {
|
||||
if (!showFallback) return null
|
||||
|
||||
// Fallback avatar
|
||||
const initial = (handle || 'U')[0].toUpperCase()
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-fallback`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#e1e1e1',
|
||||
borderRadius: '50%',
|
||||
fontSize: size * 0.4
|
||||
}}
|
||||
aria-label={alt}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={{ width: size, height: size }}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar with hover card showing user info
|
||||
*/
|
||||
export function AvatarWithCard({
|
||||
record,
|
||||
handle,
|
||||
did,
|
||||
displayName,
|
||||
apiConfig,
|
||||
...avatarProps
|
||||
}) {
|
||||
const [showCard, setShowCard] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="avatar-container"
|
||||
onMouseEnter={() => setShowCard(true)}
|
||||
onMouseLeave={() => setShowCard(false)}
|
||||
>
|
||||
<Avatar
|
||||
record={record}
|
||||
handle={handle}
|
||||
did={did}
|
||||
{...avatarProps}
|
||||
/>
|
||||
|
||||
{showCard && (
|
||||
<div className="avatar-card">
|
||||
<Avatar
|
||||
record={record}
|
||||
handle={handle}
|
||||
did={did}
|
||||
size={80}
|
||||
className="avatar-card-image"
|
||||
/>
|
||||
<div className="avatar-card-info">
|
||||
<div className="avatar-card-name">{displayName || handle}</div>
|
||||
<a
|
||||
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${did || handle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="avatar-card-handle"
|
||||
>
|
||||
@{handle}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar list component for displaying multiple avatars
|
||||
*/
|
||||
export function AvatarList({ users, maxDisplay = 5, size = 30 }) {
|
||||
const displayUsers = users.slice(0, maxDisplay)
|
||||
const remainingCount = Math.max(0, users.length - maxDisplay)
|
||||
|
||||
return (
|
||||
<div className="avatar-list">
|
||||
{displayUsers.map((user, index) => (
|
||||
<div
|
||||
key={user.handle || index}
|
||||
className="avatar-list-item"
|
||||
style={{ marginLeft: index > 0 ? -10 : 0, zIndex: displayUsers.length - index }}
|
||||
>
|
||||
<Avatar
|
||||
handle={user.handle}
|
||||
did={user.did}
|
||||
record={user.record}
|
||||
size={size}
|
||||
showFallback={true}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<div
|
||||
className="avatar-list-more"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
marginLeft: -10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#666',
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
fontSize: size * 0.4
|
||||
}}
|
||||
>
|
||||
+{remainingCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
103
oauth_new/src/components/AvatarImage.jsx
Normal file
103
oauth_new/src/components/AvatarImage.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { getValidAvatar } from '../utils/avatarFetcher.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export default function AvatarImage({ record, size = 40, className = "avatar" }) {
|
||||
const [avatarUrl, setAvatarUrl] = useState(record?.value?.author?.avatar)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
const author = record?.value?.author
|
||||
const handle = author?.handle
|
||||
const displayName = author?.displayName || handle
|
||||
|
||||
useEffect(() => {
|
||||
// record内のavatarが無い、またはエラーの場合に新しく取得
|
||||
if (!avatarUrl || error) {
|
||||
fetchValidAvatar()
|
||||
}
|
||||
}, [record, error])
|
||||
|
||||
const fetchValidAvatar = async () => {
|
||||
if (!record || loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const validAvatar = await getValidAvatar(record)
|
||||
setAvatarUrl(validAvatar)
|
||||
setError(false)
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch valid avatar:', err)
|
||||
setError(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
setError(true)
|
||||
// エラー時に再取得を試行
|
||||
fetchValidAvatar()
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setError(false)
|
||||
}
|
||||
|
||||
// ローディング中のスケルトン
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-loading`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: '50%',
|
||||
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// avatar URLがある場合
|
||||
if (avatarUrl && !error) {
|
||||
return (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={`${displayName} avatar`}
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover'
|
||||
}}
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// フォールバック: 初期文字のアバター
|
||||
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
|
||||
return (
|
||||
<div
|
||||
className={`${className} avatar-fallback`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#ddd',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.4,
|
||||
fontWeight: 'bold',
|
||||
color: '#666'
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
)
|
||||
}
|
203
oauth_new/src/components/AvatarTest.jsx
Normal file
203
oauth_new/src/components/AvatarTest.jsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
|
||||
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
|
||||
|
||||
/**
|
||||
* Test component to demonstrate avatar functionality
|
||||
*/
|
||||
export default function AvatarTest() {
|
||||
const [testResults, setTestResults] = useState({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Test data
|
||||
const testUsers = [
|
||||
{ handle: 'syui.ai', did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn' },
|
||||
{ handle: 'ai.syui.ai', did: 'did:plc:4hqjfn7m6n5hno3doamuhgef' },
|
||||
{ handle: 'yui.syui.ai', did: 'did:plc:6qyecktefllvenje24fcxnie' }
|
||||
]
|
||||
|
||||
const sampleRecord = {
|
||||
value: {
|
||||
author: {
|
||||
handle: 'syui.ai',
|
||||
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
||||
displayName: 'syui',
|
||||
avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg'
|
||||
},
|
||||
text: 'Test message',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Test functions
|
||||
const testGetAvatar = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const results = {}
|
||||
|
||||
// Test with record
|
||||
results.fromRecord = await getAvatar({ record: sampleRecord })
|
||||
|
||||
// Test with handle only
|
||||
results.fromHandle = await getAvatar({ handle: 'syui.ai' })
|
||||
|
||||
// Test with broken record (force fresh fetch)
|
||||
const brokenRecord = {
|
||||
...sampleRecord,
|
||||
value: {
|
||||
...sampleRecord.value,
|
||||
author: {
|
||||
...sampleRecord.value.author,
|
||||
avatar: 'https://broken-url.com/avatar.jpg'
|
||||
}
|
||||
}
|
||||
}
|
||||
results.brokenRecord = await getAvatar({ record: brokenRecord })
|
||||
|
||||
// Test non-existent user
|
||||
try {
|
||||
results.nonExistent = await getAvatar({ handle: 'nonexistent.user' })
|
||||
} catch (error) {
|
||||
results.nonExistent = `Error: ${error.message}`
|
||||
}
|
||||
|
||||
setTestResults(results)
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testBatchFetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const avatarMap = await batchFetchAvatars(testUsers)
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
batchResults: Object.fromEntries(avatarMap)
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Batch test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testPrefetch = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await prefetchAvatar('syui.ai')
|
||||
const cachedAvatar = await getAvatar({ handle: 'syui.ai' })
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
prefetchResult: cachedAvatar
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Prefetch test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="avatar-test-container">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Avatar System Test</h2>
|
||||
</div>
|
||||
<div className="card-content">
|
||||
|
||||
{/* Basic Avatar Examples */}
|
||||
<section className="test-section">
|
||||
<h3>Basic Avatar Examples</h3>
|
||||
<div className="avatar-examples">
|
||||
<div className="avatar-example">
|
||||
<h4>From Record</h4>
|
||||
<Avatar record={sampleRecord} size={60} />
|
||||
</div>
|
||||
|
||||
<div className="avatar-example">
|
||||
<h4>From Handle</h4>
|
||||
<Avatar handle="syui.ai" size={60} />
|
||||
</div>
|
||||
|
||||
<div className="avatar-example">
|
||||
<h4>With Fallback</h4>
|
||||
<Avatar handle="nonexistent.user" size={60} />
|
||||
</div>
|
||||
|
||||
<div className="avatar-example">
|
||||
<h4>Loading State</h4>
|
||||
<div className="avatar-loading" style={{ width: 60, height: 60 }} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Avatar with Card */}
|
||||
<section className="test-section">
|
||||
<h3>Avatar with Hover Card</h3>
|
||||
<div className="avatar-examples">
|
||||
<AvatarWithCard
|
||||
record={sampleRecord}
|
||||
displayName="syui"
|
||||
apiConfig={{ web: 'https://bsky.app' }}
|
||||
size={60}
|
||||
/>
|
||||
<p>Hover over the avatar to see the card</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Avatar List */}
|
||||
<section className="test-section">
|
||||
<h3>Avatar List</h3>
|
||||
<AvatarList users={testUsers} maxDisplay={3} size={40} />
|
||||
</section>
|
||||
|
||||
{/* Test Controls */}
|
||||
<section className="test-section">
|
||||
<h3>Test Functions</h3>
|
||||
<div className="test-controls">
|
||||
<button
|
||||
onClick={testGetAvatar}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Test getAvatar()
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={testBatchFetch}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Test Batch Fetch
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={testPrefetch}
|
||||
disabled={loading}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Test Prefetch
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Test Results */}
|
||||
{Object.keys(testResults).length > 0 && (
|
||||
<section className="test-section">
|
||||
<h3>Test Results</h3>
|
||||
<div className="json-display">
|
||||
<pre className="json-content">
|
||||
{JSON.stringify(testResults, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
246
oauth_new/src/components/AvatarTestPanel.jsx
Normal file
246
oauth_new/src/components/AvatarTestPanel.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useState } from 'react'
|
||||
import AvatarImage from './AvatarImage.jsx'
|
||||
import { getValidAvatar, clearAvatarCache, getAvatarCacheStats } from '../utils/avatarFetcher.js'
|
||||
|
||||
export default function AvatarTestPanel() {
|
||||
const [testHandle, setTestHandle] = useState('ai.syui.ai')
|
||||
const [testResult, setTestResult] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cacheStats, setCacheStats] = useState(null)
|
||||
|
||||
// ダミーレコードを作成(実際の投稿したレコード形式)
|
||||
const createTestRecord = (handle, brokenAvatar = false) => ({
|
||||
value: {
|
||||
author: {
|
||||
did: null, // DIDはnullにして、handleから取得させる
|
||||
handle: handle,
|
||||
displayName: "Test User",
|
||||
avatar: brokenAvatar ? "https://broken.example.com/avatar.jpg" : null
|
||||
},
|
||||
text: "テストコメント",
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
|
||||
const testAvatarFetch = async (useBrokenAvatar = false) => {
|
||||
setLoading(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
const testRecord = createTestRecord(testHandle, useBrokenAvatar)
|
||||
const avatarUrl = await getValidAvatar(testRecord)
|
||||
|
||||
setTestResult({
|
||||
success: true,
|
||||
avatarUrl,
|
||||
handle: testHandle,
|
||||
brokenTest: useBrokenAvatar,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: error.message,
|
||||
handle: testHandle,
|
||||
brokenTest: useBrokenAvatar
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearCache = () => {
|
||||
clearAvatarCache()
|
||||
setCacheStats(null)
|
||||
alert('Avatar cache cleared!')
|
||||
}
|
||||
|
||||
const handleShowCacheStats = () => {
|
||||
const stats = getAvatarCacheStats()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="test-ui">
|
||||
<h2>🖼️ Avatar Test Panel</h2>
|
||||
<p className="description">
|
||||
Avatar取得システムのテスト。投稿済みのdummy recordを使用してavatar取得処理を確認できます。
|
||||
</p>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="test-handle">Test Handle:</label>
|
||||
<input
|
||||
id="test-handle"
|
||||
type="text"
|
||||
value={testHandle}
|
||||
onChange={(e) => setTestHandle(e.target.value)}
|
||||
placeholder="ai.syui.ai"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
onClick={() => testAvatarFetch(false)}
|
||||
disabled={loading || !testHandle.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? '⏳ Testing...' : '🔄 Test Avatar Fetch'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => testAvatarFetch(true)}
|
||||
disabled={loading || !testHandle.trim()}
|
||||
className="btn btn-outline"
|
||||
>
|
||||
{loading ? '⏳ Testing...' : '💥 Test Broken Avatar'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
disabled={loading}
|
||||
className="btn btn-danger btn-sm"
|
||||
>
|
||||
🗑️ Clear Cache
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShowCacheStats}
|
||||
disabled={loading}
|
||||
className="btn btn-outline btn-sm"
|
||||
>
|
||||
📊 Cache Stats
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className="test-result">
|
||||
<h3>Test Result:</h3>
|
||||
{testResult.success ? (
|
||||
<div className="success-message">
|
||||
✅ Avatar fetched successfully!
|
||||
<div className="result-details">
|
||||
<p><strong>Handle:</strong> {testResult.handle}</p>
|
||||
<p><strong>Broken Test:</strong> {testResult.brokenTest ? 'Yes' : 'No'}</p>
|
||||
<p><strong>Avatar URL:</strong> {testResult.avatarUrl || 'None'}</p>
|
||||
<p><strong>Timestamp:</strong> {testResult.timestamp}</p>
|
||||
|
||||
{testResult.avatarUrl && (
|
||||
<div className="avatar-preview">
|
||||
<p><strong>Preview:</strong></p>
|
||||
<img
|
||||
src={testResult.avatarUrl}
|
||||
alt="Avatar preview"
|
||||
style={{
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: '50%',
|
||||
objectFit: 'cover',
|
||||
border: '2px solid #ddd'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="error-message">
|
||||
❌ Test failed: {testResult.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cacheStats && (
|
||||
<div className="cache-stats">
|
||||
<h3>Cache Statistics:</h3>
|
||||
<p><strong>Entries:</strong> {cacheStats.size}</p>
|
||||
{cacheStats.entries.length > 0 && (
|
||||
<div className="cache-entries">
|
||||
<h4>Cached Avatars:</h4>
|
||||
{cacheStats.entries.map((entry, i) => (
|
||||
<div key={i} className="cache-entry">
|
||||
<p><strong>Key:</strong> {entry.key}</p>
|
||||
<p><strong>Age:</strong> {Math.floor(entry.age / 1000)}s</p>
|
||||
<p><strong>Profile:</strong> {entry.profile?.displayName} (@{entry.profile?.handle})</p>
|
||||
{entry.avatar && (
|
||||
<img
|
||||
src={entry.avatar}
|
||||
alt="Cached avatar"
|
||||
style={{ width: 30, height: 30, borderRadius: '50%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="live-demo">
|
||||
<h3>Live Avatar Component Demo:</h3>
|
||||
<p>実際のAvatarImageコンポーネントの動作確認:</p>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
|
||||
<AvatarImage record={createTestRecord(testHandle, false)} size={40} />
|
||||
<span>Normal avatar test</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
|
||||
<AvatarImage record={createTestRecord(testHandle, true)} size={40} />
|
||||
<span>Broken avatar test (should fetch fresh)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.test-result {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.result-details {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.result-details p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
.avatar-preview {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
}
|
||||
.cache-stats {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f0f8ff;
|
||||
}
|
||||
.cache-entries {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cache-entry {
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cache-entry p {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.live-demo {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
135
oauth_new/src/components/CommentForm.jsx
Normal file
135
oauth_new/src/components/CommentForm.jsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto, collections } 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 {
|
||||
const currentUrl = url.trim()
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
// Create ai.syui.log record structure (new unified format)
|
||||
const record = {
|
||||
repo: user.did,
|
||||
collection: env.collection,
|
||||
rkey: `comment-${Date.now()}`,
|
||||
record: {
|
||||
$type: env.collection,
|
||||
url: currentUrl, // Keep for backward compatibility
|
||||
post: {
|
||||
url: currentUrl,
|
||||
date: timestamp,
|
||||
slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
|
||||
tags: [],
|
||||
title: document.title || 'Comment',
|
||||
language: 'ja'
|
||||
},
|
||||
text: text.trim(),
|
||||
type: 'comment',
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// Post the record
|
||||
await atproto.putRecord(null, record, agent)
|
||||
|
||||
// キャッシュを無効化
|
||||
collections.invalidateCache(env.collection)
|
||||
|
||||
// Clear form
|
||||
setText('')
|
||||
setUrl('')
|
||||
|
||||
// Notify parent component
|
||||
if (onCommentPosted) {
|
||||
onCommentPosted()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '40px',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<p>ログインしてコメントを投稿</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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}
|
||||
className="form-input"
|
||||
/>
|
||||
</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}
|
||||
className="form-input form-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
エラー: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !text.trim() || !url.trim()}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{loading ? '投稿中...' : 'コメントを投稿'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
98
oauth_new/src/components/LoadingSkeleton.jsx
Normal file
98
oauth_new/src/components/LoadingSkeleton.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
|
||||
return (
|
||||
<div className="loading-skeleton">
|
||||
{showTitle && (
|
||||
<div className="skeleton-title">
|
||||
<div className="skeleton-line title"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array(count).fill(0).map((_, i) => (
|
||||
<div key={i} className="skeleton-item">
|
||||
<div className="skeleton-avatar"></div>
|
||||
<div className="skeleton-content">
|
||||
<div className="skeleton-line name"></div>
|
||||
<div className="skeleton-line text"></div>
|
||||
<div className="skeleton-line text short"></div>
|
||||
<div className="skeleton-line meta"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<style jsx>{`
|
||||
.loading-skeleton {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
display: flex;
|
||||
padding: 15px;
|
||||
border: 1px solid #eee;
|
||||
margin: 10px 0;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s infinite;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-line.title {
|
||||
height: 20px;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.skeleton-line.name {
|
||||
height: 14px;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
.skeleton-line.text {
|
||||
height: 12px;
|
||||
width: 90%;
|
||||
}
|
||||
|
||||
.skeleton-line.text.short {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton-line.meta {
|
||||
height: 10px;
|
||||
width: 40%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
50
oauth_new/src/components/OAuthCallback.jsx
Normal file
50
oauth_new/src/components/OAuthCallback.jsx
Normal file
@@ -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 (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h2>OAuth認証</h2>
|
||||
<p>{status}</p>
|
||||
{status.includes('エラー') && (
|
||||
<button onClick={() => window.location.href = '/'}>
|
||||
メインページに戻る
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
137
oauth_new/src/components/RecordList.jsx
Normal file
137
oauth_new/src/components/RecordList.jsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import React, { useState } from 'react'
|
||||
import AvatarImage from './AvatarImage.jsx'
|
||||
import Avatar from './Avatar.jsx'
|
||||
|
||||
export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
|
||||
const [expandedRecords, setExpandedRecords] = useState(new Set())
|
||||
const [deletingRecords, setDeletingRecords] = useState(new Set())
|
||||
|
||||
const toggleJsonView = (index) => {
|
||||
const newExpanded = new Set(expandedRecords)
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index)
|
||||
} else {
|
||||
newExpanded.add(index)
|
||||
}
|
||||
setExpandedRecords(newExpanded)
|
||||
}
|
||||
|
||||
const handleDelete = async (record, index) => {
|
||||
if (!user || !agent || !record.uri) return
|
||||
|
||||
const confirmed = window.confirm('このレコードを削除しますか?')
|
||||
if (!confirmed) return
|
||||
|
||||
setDeletingRecords(prev => new Set([...prev, index]))
|
||||
|
||||
try {
|
||||
// Extract repo, collection, rkey from URI
|
||||
const uriParts = record.uri.split('/')
|
||||
const repo = uriParts[2]
|
||||
const collection = uriParts[3]
|
||||
const rkey = uriParts[4]
|
||||
|
||||
await agent.com.atproto.repo.deleteRecord({
|
||||
repo: repo,
|
||||
collection: collection,
|
||||
rkey: rkey
|
||||
})
|
||||
|
||||
if (onRecordDeleted) {
|
||||
onRecordDeleted()
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`削除に失敗しました: ${error.message}`)
|
||||
} finally {
|
||||
setDeletingRecords(prev => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(index)
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const canDelete = (record) => {
|
||||
return user && agent && record.uri && record.value.author?.did === user.did
|
||||
}
|
||||
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} className="record-item">
|
||||
<div className="record-header">
|
||||
<AvatarImage record={record} size={40} />
|
||||
<div className="user-info">
|
||||
<div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
|
||||
<div className="handle">
|
||||
<a
|
||||
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${record.value.author?.did}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="handle-link"
|
||||
>
|
||||
@{record.value.author?.handle}
|
||||
</a>
|
||||
</div>
|
||||
<div className="timestamp">{new Date(record.value.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
|
||||
<div className="record-actions">
|
||||
<button
|
||||
onClick={() => toggleJsonView(i)}
|
||||
className={`btn btn-sm ${expandedRecords.has(i) ? 'btn-outline' : 'btn-primary'}`}
|
||||
title="Show/Hide JSON"
|
||||
>
|
||||
{expandedRecords.has(i) ? 'hide' : 'json'}
|
||||
</button>
|
||||
|
||||
{canDelete(record) && (
|
||||
<button
|
||||
onClick={() => handleDelete(record, i)}
|
||||
disabled={deletingRecords.has(i)}
|
||||
className="btn btn-danger btn-sm"
|
||||
title="Delete Record"
|
||||
>
|
||||
{deletingRecords.has(i) ? 'deleting...' : 'delete'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedRecords.has(i) && (
|
||||
<div className="json-display">
|
||||
<div className="json-header">json data</div>
|
||||
<pre className="json-content">
|
||||
{JSON.stringify(record, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="record-content">{record.value.text || record.value.content}</div>
|
||||
|
||||
<div className="record-meta">
|
||||
{record.value.post?.url && (
|
||||
<a
|
||||
href={record.value.post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="record-url"
|
||||
>
|
||||
{record.value.post.url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
129
oauth_new/src/components/RecordTabs.jsx
Normal file
129
oauth_new/src/components/RecordTabs.jsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState } from 'react'
|
||||
import RecordList from './RecordList.jsx'
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||
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 || [])
|
||||
const filteredBaseRecords = filterRecords(baseRecords || [])
|
||||
|
||||
return (
|
||||
<div className="record-tabs">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('lang')}
|
||||
>
|
||||
Lang ({filteredLangRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('comment')}
|
||||
>
|
||||
Comment ({filteredCommentRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
>
|
||||
Posts ({filteredBaseRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
Users ({filteredUserComments.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'lang' && (
|
||||
!langRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredLangRecords}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'comment' && (
|
||||
!commentRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredCommentRecords}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'collection' && (
|
||||
!baseRecords ? (
|
||||
<LoadingSkeleton count={2} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredBaseRecords}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'users' && (
|
||||
!userComments ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
<RecordList
|
||||
title=""
|
||||
records={filteredUserComments}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
showTitle={false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
531
oauth_new/src/components/TestUI.jsx
Normal file
531
oauth_new/src/components/TestUI.jsx
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState } from 'react'
|
||||
import { env } from '../config/env.js'
|
||||
import AvatarTestPanel from './AvatarTestPanel.jsx'
|
||||
import AvatarTest from './AvatarTest.jsx'
|
||||
|
||||
export default function TestUI() {
|
||||
const [activeTab, setActiveTab] = useState('putRecord')
|
||||
const [accessJwt, setAccessJwt] = useState('')
|
||||
const [handle, setHandle] = useState('')
|
||||
const [sessionDid, setSessionDid] = useState('')
|
||||
const [collection, setCollection] = useState('ai.syui.log')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [success, setSuccess] = useState(null)
|
||||
const [showJson, setShowJson] = useState(false)
|
||||
const [lastRecord, setLastRecord] = useState(null)
|
||||
|
||||
const collections = [
|
||||
'ai.syui.log',
|
||||
'ai.syui.log.chat',
|
||||
'ai.syui.log.chat.lang',
|
||||
'ai.syui.log.chat.comment'
|
||||
]
|
||||
|
||||
const generateDummyData = (collectionType) => {
|
||||
const timestamp = new Date().toISOString()
|
||||
const url = 'https://syui.ai/test/dummy'
|
||||
|
||||
const basePost = {
|
||||
url: url,
|
||||
date: timestamp,
|
||||
slug: 'dummy-test',
|
||||
tags: ['test', 'dummy'],
|
||||
title: 'Test Post',
|
||||
language: 'ja'
|
||||
}
|
||||
|
||||
const baseAuthor = {
|
||||
did: sessionDid || null, // Use real session DID if available, otherwise null
|
||||
handle: handle || 'test.user',
|
||||
displayName: 'Test User',
|
||||
avatar: null
|
||||
}
|
||||
|
||||
switch (collectionType) {
|
||||
case 'ai.syui.log':
|
||||
return {
|
||||
$type: collectionType,
|
||||
url: url,
|
||||
post: basePost,
|
||||
text: 'テストコメントです。これはダミーデータです。',
|
||||
type: 'comment',
|
||||
author: baseAuthor,
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
case 'ai.syui.log.chat':
|
||||
const isQuestion = Math.random() > 0.5
|
||||
return {
|
||||
$type: collectionType,
|
||||
post: basePost,
|
||||
text: isQuestion ? 'これはテスト用の質問です。' : 'これはテスト用のAI回答です。詳しく説明します。',
|
||||
type: isQuestion ? 'question' : 'answer',
|
||||
author: isQuestion ? baseAuthor : {
|
||||
did: 'did:plc:ai-test',
|
||||
handle: 'ai.syui.ai',
|
||||
displayName: 'ai',
|
||||
avatar: null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
case 'ai.syui.log.chat.lang':
|
||||
return {
|
||||
$type: collectionType,
|
||||
post: basePost,
|
||||
text: 'This is a test translation. Hello, this is a dummy English translation of the Japanese post.',
|
||||
type: 'en',
|
||||
author: {
|
||||
did: 'did:plc:ai-test',
|
||||
handle: 'ai.syui.ai',
|
||||
displayName: 'ai',
|
||||
avatar: null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
case 'ai.syui.log.chat.comment':
|
||||
return {
|
||||
$type: collectionType,
|
||||
post: basePost,
|
||||
text: 'これはAIによるテストコメントです。記事についての感想や補足情報を提供します。',
|
||||
author: {
|
||||
did: 'did:plc:ai-test',
|
||||
handle: 'ai.syui.ai',
|
||||
displayName: 'ai',
|
||||
avatar: null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!accessJwt.trim() || !handle.trim()) {
|
||||
setError('Access JWT and Handle are required')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
const recordData = generateDummyData(collection)
|
||||
const rkey = `test-${Date.now()}`
|
||||
|
||||
const record = {
|
||||
repo: handle, // Use handle as is, without adding .bsky.social
|
||||
collection: collection,
|
||||
rkey: rkey,
|
||||
record: recordData
|
||||
}
|
||||
|
||||
setLastRecord(record)
|
||||
|
||||
// Direct API call with accessJwt
|
||||
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.putRecord`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessJwt}`
|
||||
},
|
||||
body: JSON.stringify(record)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(`API Error: ${response.status} - ${errorData.message || response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setSuccess(`Record created successfully! URI: ${result.uri}`)
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!lastRecord || !accessJwt.trim()) {
|
||||
setError('No record to delete or missing access JWT')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const deleteData = {
|
||||
repo: lastRecord.repo,
|
||||
collection: lastRecord.collection,
|
||||
rkey: lastRecord.rkey
|
||||
}
|
||||
|
||||
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.deleteRecord`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessJwt}`
|
||||
},
|
||||
body: JSON.stringify(deleteData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(`Delete Error: ${response.status} - ${errorData.message || response.statusText}`)
|
||||
}
|
||||
|
||||
setSuccess('Record deleted successfully!')
|
||||
setLastRecord(null)
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="test-ui">
|
||||
<h2>🧪 Test UI</h2>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="test-tabs">
|
||||
<button
|
||||
onClick={() => setActiveTab('putRecord')}
|
||||
className={`test-tab ${activeTab === 'putRecord' ? 'active' : ''}`}
|
||||
>
|
||||
Manual putRecord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('avatar')}
|
||||
className={`test-tab ${activeTab === 'avatar' ? 'active' : ''}`}
|
||||
>
|
||||
Avatar System
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'putRecord' && (
|
||||
<div className="test-content">
|
||||
<p className="description">
|
||||
OAuth不要のテスト用UI。accessJwtとhandleを直接入力して各collectionにダミーデータを投稿できます。
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="access-jwt">Access JWT:</label>
|
||||
<textarea
|
||||
id="access-jwt"
|
||||
value={accessJwt}
|
||||
onChange={(e) => setAccessJwt(e.target.value)}
|
||||
placeholder="eyJ... (Access JWT token)"
|
||||
rows={3}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="handle">Handle:</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
placeholder="user.bsky.social"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="session-did">Session DID (optional):</label>
|
||||
<input
|
||||
id="session-did"
|
||||
type="text"
|
||||
value={sessionDid}
|
||||
onChange={(e) => setSessionDid(e.target.value)}
|
||||
placeholder="did:plc:xxxxx (Leave empty to use test DID)"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="collection">Collection:</label>
|
||||
<select
|
||||
id="collection"
|
||||
value={collection}
|
||||
onChange={(e) => setCollection(e.target.value)}
|
||||
disabled={loading}
|
||||
>
|
||||
{collections.map(col => (
|
||||
<option key={col} value={col}>{col}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
❌ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
✅ {success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !accessJwt.trim() || !handle.trim()}
|
||||
className="submit-btn"
|
||||
>
|
||||
{loading ? '⏳ Creating...' : '📤 Create Record'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowJson(!showJson)}
|
||||
className="json-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
{showJson ? '🙈 Hide JSON' : '👁️ Show JSON'}
|
||||
</button>
|
||||
|
||||
{lastRecord && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="delete-btn"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '⏳ Deleting...' : '🗑️ Delete Last Record'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{showJson && (
|
||||
<div className="json-preview">
|
||||
<h3>Generated JSON:</h3>
|
||||
<pre>{JSON.stringify(generateDummyData(collection), null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastRecord && (
|
||||
<div className="last-record">
|
||||
<h3>Last Created Record:</h3>
|
||||
<div className="record-info">
|
||||
<p><strong>Collection:</strong> {lastRecord.collection}</p>
|
||||
<p><strong>RKey:</strong> {lastRecord.rkey}</p>
|
||||
<p><strong>Repo:</strong> {lastRecord.repo}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'avatar' && (
|
||||
<div className="test-content">
|
||||
<AvatarTestPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.test-ui {
|
||||
border: 3px solid #ff6b6b;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background: #fff5f5;
|
||||
}
|
||||
.test-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.test-tab {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px 4px 0 0;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.test-tab:hover {
|
||||
background: #e9ecef;
|
||||
color: #333;
|
||||
}
|
||||
.test-tab.active {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
border-color: #ff6b6b;
|
||||
}
|
||||
.test-content {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.test-ui h2 {
|
||||
color: #ff6b6b;
|
||||
margin-top: 0;
|
||||
}
|
||||
.description {
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
font-family: monospace;
|
||||
}
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #ff6b6b;
|
||||
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.25);
|
||||
}
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled,
|
||||
.form-group select:disabled {
|
||||
background: #f8f9fa;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.success-message {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.submit-btn {
|
||||
background: #ff6b6b;
|
||||
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: #ff5252;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.json-btn {
|
||||
background: #17a2b8;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.json-btn:hover:not(:disabled) {
|
||||
background: #138496;
|
||||
}
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.delete-btn:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
}
|
||||
.json-preview {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.json-preview h3 {
|
||||
margin-top: 0;
|
||||
color: #495057;
|
||||
}
|
||||
.json-preview pre {
|
||||
background: #e9ecef;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
.last-record {
|
||||
margin-top: 20px;
|
||||
padding: 15px;
|
||||
background: #e7f3ff;
|
||||
border: 1px solid #b3d9ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.last-record h3 {
|
||||
margin-top: 0;
|
||||
color: #0066cc;
|
||||
}
|
||||
.record-info p {
|
||||
margin: 5px 0;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
115
oauth_new/src/components/UserLookup.jsx
Normal file
115
oauth_new/src/components/UserLookup.jsx
Normal file
@@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
17
oauth_new/src/config/env.js
Normal file
17
oauth_new/src/config/env.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
69
oauth_new/src/hooks/useAdminData.js
Normal file
69
oauth_new/src/hooks/useAdminData.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { atproto, collections } from '../api/atproto.js'
|
||||
import { getApiConfig } from '../utils/pds.js'
|
||||
import { env } from '../config/env.js'
|
||||
import { getErrorMessage, logError } from '../utils/errorHandler.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)
|
||||
const [retryCount, setRetryCount] = useState(0)
|
||||
|
||||
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)
|
||||
setRetryCount(0) // 成功時はリトライカウントをリセット
|
||||
} catch (err) {
|
||||
logError(err, 'useAdminData.loadAdminData')
|
||||
setError(getErrorMessage(err))
|
||||
|
||||
// 自動リトライ(最大3回)
|
||||
if (retryCount < 3) {
|
||||
setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1)
|
||||
loadAdminData()
|
||||
}, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
loading,
|
||||
error,
|
||||
retryCount,
|
||||
refresh: loadAdminData
|
||||
}
|
||||
}
|
234
oauth_new/src/hooks/useAskAI.js
Normal file
234
oauth_new/src/hooks/useAskAI.js
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react'
|
||||
import { atproto, collections } from '../api/atproto.js'
|
||||
import { env } from '../config/env.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
import { getErrorMessage, logError } from '../utils/errorHandler.js'
|
||||
|
||||
export function useAskAI(adminData, userProfile, agent) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [chatHistory, setChatHistory] = useState([])
|
||||
|
||||
// ask-AIサーバーのURL(環境変数から取得、フォールバック付き)
|
||||
const askAIUrl = import.meta.env.VITE_ASK_AI_URL || 'http://localhost:3000/ask'
|
||||
|
||||
const askQuestion = async (question) => {
|
||||
if (!question.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
logger.log('Sending question to ask-AI:', question)
|
||||
|
||||
// ask-AIサーバーにリクエスト送信
|
||||
const response = await fetch(askAIUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
question: question.trim(),
|
||||
context: {
|
||||
url: window.location.href,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ask-AI server error: ${response.status}`)
|
||||
}
|
||||
|
||||
const aiResponse = await response.json()
|
||||
logger.log('Received AI response:', aiResponse)
|
||||
|
||||
// AI回答をチャット履歴に追加
|
||||
const chatEntry = {
|
||||
id: `chat-${Date.now()}`,
|
||||
question: question.trim(),
|
||||
answer: aiResponse.answer || 'エラーが発生しました',
|
||||
timestamp: new Date().toISOString(),
|
||||
user: userProfile ? {
|
||||
did: userProfile.did,
|
||||
handle: userProfile.handle,
|
||||
displayName: userProfile.displayName,
|
||||
avatar: userProfile.avatar
|
||||
} : null
|
||||
}
|
||||
|
||||
setChatHistory(prev => [...prev, chatEntry])
|
||||
|
||||
// atprotoにレコードを保存
|
||||
await saveChatRecord(chatEntry, aiResponse)
|
||||
|
||||
// Dispatch event for blog communication
|
||||
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
|
||||
detail: {
|
||||
question: chatEntry.question,
|
||||
answer: chatEntry.answer,
|
||||
timestamp: chatEntry.timestamp,
|
||||
aiProfile: adminData?.profile ? {
|
||||
did: adminData.did,
|
||||
handle: adminData.profile.handle,
|
||||
displayName: adminData.profile.displayName,
|
||||
avatar: adminData.profile.avatar
|
||||
} : null
|
||||
}
|
||||
}))
|
||||
|
||||
return aiResponse
|
||||
|
||||
} catch (err) {
|
||||
logError(err, 'useAskAI.askQuestion')
|
||||
setError(getErrorMessage(err))
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const saveChatRecord = async (chatEntry, aiResponse) => {
|
||||
if (!agent || !adminData?.did) {
|
||||
logger.warn('Cannot save chat record: missing agent or admin data')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const currentUrl = window.location.href
|
||||
const timestamp = chatEntry.timestamp
|
||||
const baseRkey = `${new Date(timestamp).toISOString().replace(/[:.]/g, '-').slice(0, -5)}Z`
|
||||
|
||||
// Post metadata (共通)
|
||||
const postMetadata = {
|
||||
url: currentUrl,
|
||||
date: timestamp,
|
||||
slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
|
||||
tags: [],
|
||||
title: document.title || 'AI Chat',
|
||||
language: 'ja'
|
||||
}
|
||||
|
||||
// Question record (ユーザーの質問)
|
||||
const questionRecord = {
|
||||
repo: adminData.did,
|
||||
collection: `${env.collection}.chat`,
|
||||
rkey: baseRkey,
|
||||
record: {
|
||||
$type: `${env.collection}.chat`,
|
||||
post: postMetadata,
|
||||
text: chatEntry.question,
|
||||
type: 'question',
|
||||
author: chatEntry.user ? {
|
||||
did: chatEntry.user.did,
|
||||
handle: chatEntry.user.handle,
|
||||
displayName: chatEntry.user.displayName,
|
||||
avatar: chatEntry.user.avatar
|
||||
} : {
|
||||
did: 'unknown',
|
||||
handle: 'user',
|
||||
displayName: 'User',
|
||||
avatar: null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// Answer record (AIの回答)
|
||||
const answerRecord = {
|
||||
repo: adminData.did,
|
||||
collection: `${env.collection}.chat`,
|
||||
rkey: `${baseRkey}-answer`,
|
||||
record: {
|
||||
$type: `${env.collection}.chat`,
|
||||
post: postMetadata,
|
||||
text: chatEntry.answer,
|
||||
type: 'answer',
|
||||
author: {
|
||||
did: adminData.did,
|
||||
handle: adminData.profile?.handle || 'ai',
|
||||
displayName: adminData.profile?.displayName || 'ai',
|
||||
avatar: adminData.profile?.avatar || null
|
||||
},
|
||||
createdAt: timestamp
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('Saving question record to atproto:', questionRecord)
|
||||
await atproto.putRecord(null, questionRecord, agent)
|
||||
|
||||
logger.log('Saving answer record to atproto:', answerRecord)
|
||||
await atproto.putRecord(null, answerRecord, agent)
|
||||
|
||||
// キャッシュを無効化
|
||||
collections.invalidateCache(env.collection)
|
||||
|
||||
logger.log('Chat records saved successfully')
|
||||
|
||||
} catch (err) {
|
||||
logError(err, 'useAskAI.saveChatRecord')
|
||||
// 保存エラーは致命的ではないので、UIエラーにはしない
|
||||
}
|
||||
}
|
||||
|
||||
const clearChatHistory = () => {
|
||||
setChatHistory([])
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const loadChatHistory = async () => {
|
||||
if (!adminData?.did) return
|
||||
|
||||
try {
|
||||
const records = await collections.getChat(
|
||||
adminData.apiConfig.pds,
|
||||
adminData.did,
|
||||
env.collection
|
||||
)
|
||||
|
||||
// Group records by timestamp and create Q&A pairs
|
||||
const recordGroups = {}
|
||||
|
||||
records.forEach(record => {
|
||||
const timestamp = record.value.createdAt
|
||||
const baseKey = timestamp.replace('-answer', '')
|
||||
|
||||
if (!recordGroups[baseKey]) {
|
||||
recordGroups[baseKey] = {}
|
||||
}
|
||||
|
||||
if (record.value.type === 'question') {
|
||||
recordGroups[baseKey].question = record.value.text
|
||||
recordGroups[baseKey].user = record.value.author
|
||||
recordGroups[baseKey].timestamp = timestamp
|
||||
recordGroups[baseKey].id = record.uri
|
||||
} else if (record.value.type === 'answer') {
|
||||
recordGroups[baseKey].answer = record.value.text
|
||||
recordGroups[baseKey].timestamp = timestamp
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to history format, only include complete Q&A pairs
|
||||
const history = Object.values(recordGroups)
|
||||
.filter(group => group.question && group.answer)
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
|
||||
.slice(-10) // 最新10件のみ
|
||||
|
||||
setChatHistory(history)
|
||||
logger.log('Chat history loaded:', history.length, 'entries')
|
||||
|
||||
} catch (err) {
|
||||
logError(err, 'useAskAI.loadChatHistory')
|
||||
// 履歴読み込みエラーは致命的ではない
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
askQuestion,
|
||||
loading,
|
||||
error,
|
||||
chatHistory,
|
||||
clearChatHistory,
|
||||
loadChatHistory
|
||||
}
|
||||
}
|
47
oauth_new/src/hooks/useAuth.js
Normal file
47
oauth_new/src/hooks/useAuth.js
Normal file
@@ -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
|
||||
}
|
||||
}
|
33
oauth_new/src/hooks/usePageContext.js
Normal file
33
oauth_new/src/hooks/usePageContext.js
Normal file
@@ -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
|
||||
}
|
169
oauth_new/src/hooks/useUserData.js
Normal file
169
oauth_new/src/hooks/useUserData.js
Normal file
@@ -0,0 +1,169 @@
|
||||
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 from ai.syui.log.chat
|
||||
const chatRecords = await collections.getChat(
|
||||
adminData.apiConfig.pds,
|
||||
adminData.did,
|
||||
env.collection
|
||||
)
|
||||
setChatRecords(chatRecords)
|
||||
|
||||
// 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 }
|
||||
}
|
6
oauth_new/src/main.jsx
Normal file
6
oauth_new/src/main.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './App.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('comment-atproto')).render(<App />)
|
158
oauth_new/src/services/oauth.js
Normal file
158
oauth_new/src/services/oauth.js
Normal file
@@ -0,0 +1,158 @@
|
||||
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'
|
||||
|
||||
// Create Agent directly with session (per official docs)
|
||||
try {
|
||||
this.agent = new Agent(session)
|
||||
} catch (err) {
|
||||
// Fallback to dpopFetch method
|
||||
this.agent = new Agent({
|
||||
service: session.server?.serviceEndpoint || 'https://bsky.social',
|
||||
fetch: session.dpopFetch
|
||||
})
|
||||
}
|
||||
|
||||
this.sessionInfo = { did, handle }
|
||||
|
||||
// Resolve handle if missing
|
||||
if (handle === 'unknown' && this.agent) {
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
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 transition:generic'
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
206
oauth_new/src/utils/avatar.js
Normal file
206
oauth_new/src/utils/avatar.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from 'react'
|
||||
import { atproto } from '../api/atproto.js'
|
||||
import { getPdsFromHandle, getApiConfig } from './pds.js'
|
||||
import { dataCache } from './cache.js'
|
||||
import { logError } from './errorHandler.js'
|
||||
|
||||
// Cache duration for avatar URLs (30 minutes)
|
||||
const AVATAR_CACHE_DURATION = 30 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Avatar fetching utility with fallback mechanism
|
||||
*
|
||||
* Strategy:
|
||||
* 1. First check if avatar exists in the record
|
||||
* 2. If avatar is missing/broken, fetch fresh data from ATProto
|
||||
* 3. Cache results to avoid excessive API calls
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract avatar URL from record if available
|
||||
* @param {Object} record - The record object
|
||||
* @returns {string|null} Avatar URL or null
|
||||
*/
|
||||
function getAvatarFromRecord(record) {
|
||||
const avatar = record?.value?.author?.avatar
|
||||
if (avatar && typeof avatar === 'string' && avatar.startsWith('http')) {
|
||||
return avatar
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch fresh avatar data from ATProto
|
||||
* @param {string} handle - User handle
|
||||
* @param {string} did - User DID (optional, will fetch if not provided)
|
||||
* @returns {Promise<string|null>} Avatar URL or null
|
||||
*/
|
||||
async function fetchFreshAvatar(handle, did = null) {
|
||||
try {
|
||||
// Step 1: Get PDS from handle
|
||||
const pds = await getPdsFromHandle(handle)
|
||||
const apiConfig = getApiConfig(pds)
|
||||
|
||||
// Step 2: Get DID if not provided
|
||||
if (!did) {
|
||||
const pdsHost = pds.replace(/^https?:\/\//, '')
|
||||
const repoData = await atproto.getDid(pdsHost, handle)
|
||||
did = repoData
|
||||
}
|
||||
|
||||
// Step 3: Get profile from bsky API
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
// Return avatar URL
|
||||
return profile?.avatar || null
|
||||
} catch (error) {
|
||||
logError(error, 'Avatar Fetch')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get avatar with intelligent fallback
|
||||
* @param {Object} options - Options object
|
||||
* @param {Object} options.record - Record object (optional)
|
||||
* @param {string} options.handle - User handle (required if no record)
|
||||
* @param {string} options.did - User DID (optional)
|
||||
* @param {boolean} options.forceFresh - Force fresh fetch even if cached
|
||||
* @returns {Promise<string|null>} Avatar URL or null
|
||||
*/
|
||||
export async function getAvatar({ record, handle, did, forceFresh = false }) {
|
||||
// Extract handle and DID from record if available
|
||||
if (record && !handle) {
|
||||
handle = record.value?.author?.handle
|
||||
did = record.value?.author?.did
|
||||
}
|
||||
|
||||
if (!handle) {
|
||||
throw new Error('Handle is required to fetch avatar')
|
||||
}
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = `avatar:${handle}`
|
||||
|
||||
// Check cache first (unless forceFresh)
|
||||
if (!forceFresh) {
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get avatar from record first
|
||||
if (record) {
|
||||
const recordAvatar = getAvatarFromRecord(record)
|
||||
if (recordAvatar) {
|
||||
// Validate that the avatar URL is still accessible
|
||||
try {
|
||||
const response = await fetch(recordAvatar, { method: 'HEAD' })
|
||||
if (response.ok) {
|
||||
dataCache.set(cacheKey, recordAvatar, AVATAR_CACHE_DURATION)
|
||||
return recordAvatar
|
||||
}
|
||||
} catch {
|
||||
// Avatar URL is broken, proceed to fetch fresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fresh avatar data
|
||||
const freshAvatar = await fetchFreshAvatar(handle, did)
|
||||
|
||||
if (freshAvatar) {
|
||||
dataCache.set(cacheKey, freshAvatar, AVATAR_CACHE_DURATION)
|
||||
}
|
||||
|
||||
return freshAvatar
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch fetch avatars for multiple users
|
||||
* @param {Array<Object>} users - Array of user objects with handle/did
|
||||
* @returns {Promise<Map>} Map of handle -> avatar URL
|
||||
*/
|
||||
export async function batchFetchAvatars(users) {
|
||||
const avatarMap = new Map()
|
||||
|
||||
// Process in parallel with concurrency limit
|
||||
const BATCH_SIZE = 5
|
||||
for (let i = 0; i < users.length; i += BATCH_SIZE) {
|
||||
const batch = users.slice(i, i + BATCH_SIZE)
|
||||
const promises = batch.map(async (user) => {
|
||||
const avatar = await getAvatar({
|
||||
handle: user.handle,
|
||||
did: user.did
|
||||
})
|
||||
return { handle: user.handle, avatar }
|
||||
})
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
results.forEach(({ handle, avatar }) => {
|
||||
avatarMap.set(handle, avatar)
|
||||
})
|
||||
}
|
||||
|
||||
return avatarMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch and cache avatar for a handle
|
||||
* @param {string} handle - User handle
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function prefetchAvatar(handle) {
|
||||
await getAvatar({ handle })
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear avatar cache for a specific handle
|
||||
* @param {string} handle - User handle
|
||||
*/
|
||||
export function clearAvatarCache(handle) {
|
||||
if (handle) {
|
||||
dataCache.delete(`avatar:${handle}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all avatar caches
|
||||
*/
|
||||
export function clearAllAvatarCaches() {
|
||||
dataCache.invalidatePattern('avatar:')
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for avatar management
|
||||
* @param {Object} options - Options for avatar fetching
|
||||
* @returns {Object} { avatar, loading, error, refetch }
|
||||
*/
|
||||
export function useAvatar({ record, handle, did }) {
|
||||
const [state, setState] = React.useState({
|
||||
avatar: null,
|
||||
loading: true,
|
||||
error: null
|
||||
})
|
||||
|
||||
const fetchAvatar = React.useCallback(async (forceFresh = false) => {
|
||||
setState(prev => ({ ...prev, loading: true, error: null }))
|
||||
|
||||
try {
|
||||
const avatarUrl = await getAvatar({ record, handle, did, forceFresh })
|
||||
setState({ avatar: avatarUrl, loading: false, error: null })
|
||||
} catch (error) {
|
||||
setState({ avatar: null, loading: false, error: error.message })
|
||||
}
|
||||
}, [record, handle, did])
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchAvatar()
|
||||
}, [fetchAvatar])
|
||||
|
||||
return {
|
||||
...state,
|
||||
refetch: () => fetchAvatar(true)
|
||||
}
|
||||
}
|
262
oauth_new/src/utils/avatarCache.js
Normal file
262
oauth_new/src/utils/avatarCache.js
Normal file
@@ -0,0 +1,262 @@
|
||||
import { dataCache } from './cache.js'
|
||||
|
||||
/**
|
||||
* Avatar-specific cache utilities
|
||||
* Extends the base cache system with avatar-specific functionality
|
||||
*/
|
||||
|
||||
// Cache keys
|
||||
const CACHE_PREFIX = 'avatar:'
|
||||
const METADATA_KEY = 'avatar:metadata'
|
||||
|
||||
/**
|
||||
* Get cache metadata for avatars
|
||||
* @returns {Object} Metadata about avatar cache
|
||||
*/
|
||||
export function getAvatarCacheMetadata() {
|
||||
return dataCache.get(METADATA_KEY) || {
|
||||
totalCount: 0,
|
||||
lastCleanup: Date.now(),
|
||||
cacheHits: 0,
|
||||
cacheMisses: 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update cache metadata
|
||||
* @param {Object} updates - Updates to apply to metadata
|
||||
*/
|
||||
function updateMetadata(updates) {
|
||||
const current = getAvatarCacheMetadata()
|
||||
const updated = { ...current, ...updates }
|
||||
dataCache.set(METADATA_KEY, updated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Track cache hit
|
||||
*/
|
||||
export function trackCacheHit() {
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
updateMetadata({ cacheHits: metadata.cacheHits + 1 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Track cache miss
|
||||
*/
|
||||
export function trackCacheMiss() {
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
updateMetadata({ cacheMisses: metadata.cacheMisses + 1 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cached avatar handles
|
||||
* @returns {Array<string>} List of cached handles
|
||||
*/
|
||||
export function getCachedAvatarHandles() {
|
||||
// This would require enumerating cache keys
|
||||
// For now, we'll track this in metadata
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
return metadata.handles || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Add handle to cached list
|
||||
* @param {string} handle - Handle to add
|
||||
*/
|
||||
export function addCachedHandle(handle) {
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
const handles = metadata.handles || []
|
||||
if (!handles.includes(handle)) {
|
||||
handles.push(handle)
|
||||
updateMetadata({
|
||||
handles,
|
||||
totalCount: handles.length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove handle from cached list
|
||||
* @param {string} handle - Handle to remove
|
||||
*/
|
||||
export function removeCachedHandle(handle) {
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
const handles = (metadata.handles || []).filter(h => h !== handle)
|
||||
updateMetadata({
|
||||
handles,
|
||||
totalCount: handles.length
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired avatar cache entries
|
||||
* @param {number} maxAge - Maximum age in milliseconds (default: 30 minutes)
|
||||
* @returns {number} Number of entries cleaned
|
||||
*/
|
||||
export function cleanupExpiredAvatars(maxAge = 30 * 60 * 1000) {
|
||||
const now = Date.now()
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
const handles = metadata.handles || []
|
||||
let cleanedCount = 0
|
||||
|
||||
handles.forEach(handle => {
|
||||
const cacheKey = `${CACHE_PREFIX}${handle}`
|
||||
const entry = dataCache.get(cacheKey, true) // Get with metadata
|
||||
|
||||
if (entry && entry.timestamp && (now - entry.timestamp) > maxAge) {
|
||||
dataCache.delete(cacheKey)
|
||||
cleanedCount++
|
||||
}
|
||||
})
|
||||
|
||||
// Update metadata
|
||||
if (cleanedCount > 0) {
|
||||
const remainingHandles = handles.filter(handle => {
|
||||
const cacheKey = `${CACHE_PREFIX}${handle}`
|
||||
return dataCache.get(cacheKey) !== null
|
||||
})
|
||||
|
||||
updateMetadata({
|
||||
handles: remainingHandles,
|
||||
totalCount: remainingHandles.length,
|
||||
lastCleanup: now
|
||||
})
|
||||
}
|
||||
|
||||
return cleanedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
* @returns {Object} Cache statistics
|
||||
*/
|
||||
export function getAvatarCacheStats() {
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
const totalRequests = metadata.cacheHits + metadata.cacheMisses
|
||||
const hitRate = totalRequests > 0 ? (metadata.cacheHits / totalRequests * 100) : 0
|
||||
|
||||
return {
|
||||
totalCached: metadata.totalCount || 0,
|
||||
cacheHits: metadata.cacheHits || 0,
|
||||
cacheMisses: metadata.cacheMisses || 0,
|
||||
hitRate: Math.round(hitRate * 100) / 100,
|
||||
lastCleanup: metadata.lastCleanup ? new Date(metadata.lastCleanup) : null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all avatar cache data
|
||||
* @returns {number} Number of entries cleared
|
||||
*/
|
||||
export function clearAllAvatarCache() {
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
const handles = metadata.handles || []
|
||||
|
||||
handles.forEach(handle => {
|
||||
const cacheKey = `${CACHE_PREFIX}${handle}`
|
||||
dataCache.delete(cacheKey)
|
||||
})
|
||||
|
||||
// Clear metadata
|
||||
dataCache.delete(METADATA_KEY)
|
||||
|
||||
return handles.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload avatars for a list of handles
|
||||
* @param {Array<string>} handles - Handles to preload
|
||||
* @param {Function} getAvatar - Avatar fetching function
|
||||
* @returns {Promise<Map>} Map of handle -> avatar URL results
|
||||
*/
|
||||
export async function preloadAvatars(handles, getAvatar) {
|
||||
const results = new Map()
|
||||
const BATCH_SIZE = 3 // Smaller batch for preloading
|
||||
|
||||
for (let i = 0; i < handles.length; i += BATCH_SIZE) {
|
||||
const batch = handles.slice(i, i + BATCH_SIZE)
|
||||
const promises = batch.map(async (handle) => {
|
||||
try {
|
||||
const avatar = await getAvatar({ handle })
|
||||
return { handle, avatar, success: true }
|
||||
} catch (error) {
|
||||
return { handle, avatar: null, success: false, error: error.message }
|
||||
}
|
||||
})
|
||||
|
||||
const batchResults = await Promise.all(promises)
|
||||
batchResults.forEach(({ handle, avatar, success }) => {
|
||||
results.set(handle, { avatar, success })
|
||||
if (success) {
|
||||
addCachedHandle(handle)
|
||||
}
|
||||
})
|
||||
|
||||
// Small delay between batches to avoid overwhelming the API
|
||||
if (i + BATCH_SIZE < handles.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate cached avatar URLs
|
||||
* Check if cached avatar URLs are still valid
|
||||
* @param {number} sampleSize - Number of cached avatars to validate (default: 5)
|
||||
* @returns {Promise<Object>} Validation results
|
||||
*/
|
||||
export async function validateCachedAvatars(sampleSize = 5) {
|
||||
const metadata = getAvatarCacheMetadata()
|
||||
const handles = metadata.handles || []
|
||||
|
||||
if (handles.length === 0) {
|
||||
return { validCount: 0, invalidCount: 0, totalChecked: 0 }
|
||||
}
|
||||
|
||||
// Sample random handles to check
|
||||
const samplesToCheck = handles
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.slice(0, sampleSize)
|
||||
|
||||
let validCount = 0
|
||||
let invalidCount = 0
|
||||
|
||||
for (const handle of samplesToCheck) {
|
||||
const cacheKey = `${CACHE_PREFIX}${handle}`
|
||||
const avatarUrl = dataCache.get(cacheKey)
|
||||
|
||||
if (avatarUrl && typeof avatarUrl === 'string' && avatarUrl.startsWith('http')) {
|
||||
try {
|
||||
const response = await fetch(avatarUrl, { method: 'HEAD' })
|
||||
if (response.ok) {
|
||||
validCount++
|
||||
} else {
|
||||
invalidCount++
|
||||
// Remove invalid cached avatar
|
||||
dataCache.delete(cacheKey)
|
||||
removeCachedHandle(handle)
|
||||
}
|
||||
} catch {
|
||||
invalidCount++
|
||||
// Remove invalid cached avatar
|
||||
dataCache.delete(cacheKey)
|
||||
removeCachedHandle(handle)
|
||||
}
|
||||
} else {
|
||||
invalidCount++
|
||||
// Remove invalid cache entry
|
||||
dataCache.delete(cacheKey)
|
||||
removeCachedHandle(handle)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
validCount,
|
||||
invalidCount,
|
||||
totalChecked: samplesToCheck.length,
|
||||
validationRate: samplesToCheck.length > 0 ?
|
||||
Math.round((validCount / samplesToCheck.length) * 100) : 0
|
||||
}
|
||||
}
|
147
oauth_new/src/utils/avatarFetcher.js
Normal file
147
oauth_new/src/utils/avatarFetcher.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { getPdsFromHandle, getApiConfig } from './pds.js'
|
||||
import { logger } from './logger.js'
|
||||
|
||||
// Avatar取得の状態管理
|
||||
const avatarCache = new Map()
|
||||
const CACHE_DURATION = 30 * 60 * 1000 // 30分
|
||||
|
||||
// Avatar URLが有効かチェック
|
||||
async function isAvatarValid(avatarUrl) {
|
||||
if (!avatarUrl) return false
|
||||
|
||||
try {
|
||||
const response = await fetch(avatarUrl, { method: 'HEAD' })
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
logger.warn('Avatar URL check failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// handleからDIDを取得
|
||||
async function getDid(handle) {
|
||||
try {
|
||||
const pds = await getPdsFromHandle(handle)
|
||||
const response = await fetch(`${pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`)
|
||||
const data = await response.json()
|
||||
return data.did
|
||||
} catch (error) {
|
||||
logger.error('Failed to get DID for handle:', handle, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// DIDからプロフィール情報を取得
|
||||
async function getProfile(did, handle) {
|
||||
try {
|
||||
// Determine which public API to use based on handle
|
||||
const pds = await getPdsFromHandle(handle)
|
||||
const apiConfig = getApiConfig(pds)
|
||||
|
||||
// Use the appropriate public API endpoint
|
||||
const publicApiUrl = apiConfig.bsky
|
||||
|
||||
logger.log('Getting profile for DID:', did, 'using public API:', publicApiUrl)
|
||||
const response = await fetch(`${publicApiUrl}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Profile API error: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
logger.log('Profile data received:', data)
|
||||
return data
|
||||
} catch (error) {
|
||||
logger.error('Failed to get profile for DID:', did, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 新しいavatar URLを取得
|
||||
async function fetchFreshAvatar(handle, did) {
|
||||
const cacheKey = `${handle}:${did || 'no-did'}`
|
||||
const cached = avatarCache.get(cacheKey)
|
||||
|
||||
// キャッシュチェック
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
||||
logger.log('Using cached avatar for:', handle)
|
||||
return cached.avatar
|
||||
}
|
||||
|
||||
try {
|
||||
logger.log('Fetching fresh avatar for handle:', handle, 'with DID:', did)
|
||||
|
||||
// DIDが不明な場合は取得
|
||||
let actualDid = did
|
||||
if (!actualDid) {
|
||||
logger.log('No DID provided, fetching from handle:', handle)
|
||||
actualDid = await getDid(handle)
|
||||
logger.log('Got DID from handle:', actualDid)
|
||||
}
|
||||
|
||||
// プロフィール取得
|
||||
const profile = await getProfile(actualDid, handle)
|
||||
const avatarUrl = profile.avatar || null
|
||||
|
||||
// キャッシュに保存
|
||||
avatarCache.set(cacheKey, {
|
||||
avatar: avatarUrl,
|
||||
timestamp: Date.now(),
|
||||
profile: {
|
||||
displayName: profile.displayName,
|
||||
handle: profile.handle
|
||||
}
|
||||
})
|
||||
|
||||
logger.log('Fresh avatar fetched for:', handle, 'Avatar URL:', avatarUrl)
|
||||
return avatarUrl
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch fresh avatar for:', handle, 'Error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// メイン関数: avatarを取得(recordから → 新規取得)
|
||||
export async function getValidAvatar(record) {
|
||||
const author = record?.value?.author
|
||||
if (!author?.handle) {
|
||||
logger.warn('No handle found in record author')
|
||||
return null
|
||||
}
|
||||
|
||||
const { handle, did, avatar: recordAvatar } = author
|
||||
|
||||
// 1. record内のavatarをチェック
|
||||
if (recordAvatar) {
|
||||
const isValid = await isAvatarValid(recordAvatar)
|
||||
if (isValid) {
|
||||
logger.log('Using avatar from record:', recordAvatar)
|
||||
return recordAvatar
|
||||
} else {
|
||||
logger.log('Record avatar is broken, fetching fresh:', recordAvatar)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 新しいavatarを取得
|
||||
return await fetchFreshAvatar(handle, did)
|
||||
}
|
||||
|
||||
// キャッシュクリア
|
||||
export function clearAvatarCache() {
|
||||
avatarCache.clear()
|
||||
logger.log('Avatar cache cleared')
|
||||
}
|
||||
|
||||
// キャッシュ統計
|
||||
export function getAvatarCacheStats() {
|
||||
return {
|
||||
size: avatarCache.size,
|
||||
entries: Array.from(avatarCache.entries()).map(([key, value]) => ({
|
||||
key,
|
||||
avatar: value.avatar,
|
||||
age: Date.now() - value.timestamp,
|
||||
profile: value.profile
|
||||
}))
|
||||
}
|
||||
}
|
63
oauth_new/src/utils/cache.js
Normal file
63
oauth_new/src/utils/cache.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { logger } from './logger.js'
|
||||
|
||||
class SimpleCache {
|
||||
constructor(ttl = 30000) { // 30秒TTL
|
||||
this.cache = new Map()
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
generateKey(...parts) {
|
||||
return parts.filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const item = this.cache.get(key)
|
||||
if (!item) return null
|
||||
|
||||
if (Date.now() - item.timestamp > this.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.log(`Cache hit: ${key}`)
|
||||
return item.data
|
||||
}
|
||||
|
||||
set(key, data) {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
logger.log(`Cache set: ${key}`)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear()
|
||||
logger.log('Cache cleared')
|
||||
}
|
||||
|
||||
invalidatePattern(pattern) {
|
||||
let deletedCount = 0
|
||||
for (const key of this.cache.keys()) {
|
||||
if (key.includes(pattern)) {
|
||||
this.cache.delete(key)
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
logger.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
keys: Array.from(this.cache.keys())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dataCache = new SimpleCache()
|
||||
|
||||
// デバッグ用:開発環境でのみグローバルからアクセス可能にする
|
||||
if (import.meta.env.DEV) {
|
||||
window.dataCache = dataCache
|
||||
}
|
49
oauth_new/src/utils/errorHandler.js
Normal file
49
oauth_new/src/utils/errorHandler.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { logger } from './logger.js'
|
||||
|
||||
export class ATProtoError extends Error {
|
||||
constructor(message, status, context) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.context = context
|
||||
this.timestamp = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
export function getErrorMessage(error) {
|
||||
if (!error) return '不明なエラー'
|
||||
|
||||
if (error.status === 400) {
|
||||
return 'アカウントまたはレコードが見つかりません'
|
||||
} else if (error.status === 401) {
|
||||
return '認証が必要です。ログインしてください'
|
||||
} else if (error.status === 403) {
|
||||
return 'アクセス権限がありません'
|
||||
} else if (error.status === 429) {
|
||||
return 'アクセスが集中しています。しばらく待ってから再試行してください'
|
||||
} else if (error.status === 500) {
|
||||
return 'サーバーでエラーが発生しました'
|
||||
} else if (error.message?.includes('fetch')) {
|
||||
return 'ネットワーク接続を確認してください'
|
||||
} else if (error.message?.includes('timeout')) {
|
||||
return 'タイムアウトしました。再試行してください'
|
||||
}
|
||||
|
||||
return `エラーが発生しました: ${error.message || '不明'}`
|
||||
}
|
||||
|
||||
export function logError(error, context = 'Unknown') {
|
||||
const errorInfo = {
|
||||
context,
|
||||
message: error.message,
|
||||
status: error.status,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href
|
||||
}
|
||||
|
||||
logger.error(`[ATProto Error] ${context}:`, errorInfo)
|
||||
|
||||
// 本番環境では外部ログサービスに送信することも可能
|
||||
// if (import.meta.env.PROD) {
|
||||
// sendToLogService(errorInfo)
|
||||
// }
|
||||
}
|
82
oauth_new/src/utils/logger.js
Normal file
82
oauth_new/src/utils/logger.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// Logger utility with environment-based control
|
||||
class Logger {
|
||||
constructor() {
|
||||
this.isDev = import.meta.env.DEV || false
|
||||
this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
||||
this.isEnabled = this.isDev && this.debugEnabled // Enable only in dev AND when debug flag is true
|
||||
}
|
||||
|
||||
log(...args) {
|
||||
if (this.isEnabled) {
|
||||
console.log(...args)
|
||||
}
|
||||
}
|
||||
|
||||
error(...args) {
|
||||
if (this.isEnabled) {
|
||||
console.error(...args)
|
||||
}
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
if (this.isEnabled) {
|
||||
console.warn(...args)
|
||||
}
|
||||
}
|
||||
|
||||
info(...args) {
|
||||
if (this.isEnabled) {
|
||||
console.info(...args)
|
||||
}
|
||||
}
|
||||
|
||||
// グループログ
|
||||
group(label) {
|
||||
if (this.isEnabled) {
|
||||
console.group(label)
|
||||
}
|
||||
}
|
||||
|
||||
groupEnd() {
|
||||
if (this.isEnabled) {
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
|
||||
// テーブル表示
|
||||
table(data) {
|
||||
if (this.isEnabled) {
|
||||
console.table(data)
|
||||
}
|
||||
}
|
||||
|
||||
// 時間計測
|
||||
time(label) {
|
||||
if (this.isEnabled) {
|
||||
console.time(label)
|
||||
}
|
||||
}
|
||||
|
||||
timeEnd(label) {
|
||||
if (this.isEnabled) {
|
||||
console.timeEnd(label)
|
||||
}
|
||||
}
|
||||
|
||||
// ログを有効/無効にする
|
||||
enable() {
|
||||
this.isEnabled = true
|
||||
}
|
||||
|
||||
disable() {
|
||||
this.isEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// シングルトンインスタンス
|
||||
export const logger = new Logger()
|
||||
|
||||
// 開発環境でのみグローバルアクセス可能にする
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_DEBUG === 'true') {
|
||||
window._logger = logger
|
||||
}
|
36
oauth_new/src/utils/pds.js
Normal file
36
oauth_new/src/utils/pds.js
Normal file
@@ -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
|
||||
}
|
15
oauth_new/vite.config.js
Normal file
15
oauth_new/vite.config.js
Normal file
@@ -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]'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
@@ -3,10 +3,10 @@
|
||||
set -e
|
||||
|
||||
cb=ai.syui.log
|
||||
cl=( $cb.user )
|
||||
cl=( $cb.chat.lang $cb.chat.comment)
|
||||
f=~/.config/syui/ai/log/config.json
|
||||
|
||||
default_collection="ai.syui.log.chat.comment"
|
||||
default_collection="ai.syui.log.chat"
|
||||
default_pds="syu.is"
|
||||
default_did=`cat $f|jq -r .admin.did`
|
||||
default_token=`cat $f|jq -r .admin.access_jwt`
|
||||
|
@@ -6,7 +6,7 @@ function _env() {
|
||||
oauth=$d/oauth
|
||||
myblog=$d/my-blog
|
||||
port=4173
|
||||
source $oauth/.env.production
|
||||
#source $oauth/.env.production
|
||||
case $OSTYPE in
|
||||
darwin*)
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
@@ -64,6 +64,10 @@ case "${1:-serve}" in
|
||||
oauth|o)
|
||||
_oauth_build
|
||||
;;
|
||||
n)
|
||||
oauth=$d/oauth_new
|
||||
_oauth_build
|
||||
;;
|
||||
comment|co)
|
||||
_server_comment
|
||||
;;
|
||||
|
@@ -1426,35 +1426,45 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
|
||||
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
|
||||
.build()?;
|
||||
|
||||
// Try localhost first (for same-server deployment)
|
||||
let localhost_url = "http://localhost:11434/api/generate";
|
||||
match client.post(localhost_url).json(&request).send().await {
|
||||
Ok(response) if response.status().is_success() => {
|
||||
let ollama_response: OllamaResponse = response.json().await?;
|
||||
println!("{}", "✅ Used localhost Ollama".green());
|
||||
return Ok(ollama_response.response);
|
||||
}
|
||||
_ => {
|
||||
println!("{}", "⚠️ Localhost Ollama not available, trying remote...".yellow());
|
||||
}
|
||||
// Use configured Ollama host
|
||||
let ollama_url = format!("{}/api/generate", ai_config.ollama_host);
|
||||
|
||||
// Check if this is a local/private network connection (no CORS needed)
|
||||
// RFC 1918 private networks + localhost
|
||||
let is_local = ai_config.ollama_host.contains("localhost") ||
|
||||
ai_config.ollama_host.contains("127.0.0.1") ||
|
||||
ai_config.ollama_host.contains("::1") ||
|
||||
ai_config.ollama_host.contains("192.168.") || // 192.168.0.0/16
|
||||
ai_config.ollama_host.contains("10.") || // 10.0.0.0/8
|
||||
(ai_config.ollama_host.contains("172.") && { // 172.16.0.0/12
|
||||
// Extract 172.x and check if x is 16-31
|
||||
if let Some(start) = ai_config.ollama_host.find("172.") {
|
||||
let after_172 = &ai_config.ollama_host[start + 4..];
|
||||
if let Some(dot_pos) = after_172.find('.') {
|
||||
if let Ok(second_octet) = after_172[..dot_pos].parse::<u8>() {
|
||||
second_octet >= 16 && second_octet <= 31
|
||||
} else { false }
|
||||
} else { false }
|
||||
} else { false }
|
||||
});
|
||||
|
||||
let mut request_builder = client.post(&ollama_url).json(&request);
|
||||
|
||||
if !is_local {
|
||||
println!("{}", format!("🔗 Making request to: {} with Origin: {}", ollama_url, ai_config.blog_host).blue());
|
||||
request_builder = request_builder.header("Origin", &ai_config.blog_host);
|
||||
} else {
|
||||
println!("{}", format!("🔗 Making request to local network: {}", ollama_url).blue());
|
||||
}
|
||||
|
||||
// Fallback to remote host
|
||||
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
|
||||
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
|
||||
let response = client
|
||||
.post(&remote_url)
|
||||
.header("Origin", &ai_config.blog_host)
|
||||
.json(&request)
|
||||
.send()
|
||||
.await?;
|
||||
let response = request_builder.send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
|
||||
}
|
||||
|
||||
let ollama_response: OllamaResponse = response.json().await?;
|
||||
println!("{}", "✅ Used remote Ollama".green());
|
||||
println!("{}", "✅ Ollama request successful".green());
|
||||
Ok(ollama_response.response)
|
||||
}
|
||||
|
||||
|
14
src/lib.rs
Normal file
14
src/lib.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Export modules for testing
|
||||
pub mod ai;
|
||||
pub mod analyzer;
|
||||
pub mod atproto;
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
pub mod doc_generator;
|
||||
pub mod generator;
|
||||
pub mod markdown;
|
||||
pub mod mcp;
|
||||
pub mod oauth;
|
||||
// pub mod ollama_proxy; // Temporarily disabled - uses actix-web instead of axum
|
||||
pub mod template;
|
||||
pub mod translator;
|
@@ -2,6 +2,7 @@ use anyhow::Result;
|
||||
use regex::Regex;
|
||||
use super::MarkdownSection;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct MarkdownParser {
|
||||
_code_block_regex: Regex,
|
||||
header_regex: Regex,
|
||||
|
@@ -42,9 +42,9 @@ pub enum MarkdownSection {
|
||||
|
||||
pub trait Translator {
|
||||
#[allow(dead_code)]
|
||||
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String>;
|
||||
async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String>;
|
||||
async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>>;
|
||||
fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||
fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||
fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -67,6 +67,7 @@ pub struct TranslationMetrics {
|
||||
pub sections_preserved: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LanguageMapping {
|
||||
pub mappings: HashMap<String, LanguageInfo>,
|
||||
}
|
||||
|
@@ -5,6 +5,7 @@ use std::time::Instant;
|
||||
use super::*;
|
||||
use crate::translator::markdown_parser::MarkdownParser;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct OllamaTranslator {
|
||||
client: Client,
|
||||
language_mapping: LanguageMapping,
|
||||
@@ -129,12 +130,15 @@ Translation:"#,
|
||||
}
|
||||
|
||||
impl Translator for OllamaTranslator {
|
||||
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String> {
|
||||
fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
|
||||
async move {
|
||||
let prompt = self.build_translation_prompt(content, config)?;
|
||||
self.call_ollama(&prompt, config).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String> {
|
||||
fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
|
||||
async move {
|
||||
println!("🔄 Parsing markdown content...");
|
||||
let sections = self.parser.parse_markdown(content)?;
|
||||
|
||||
@@ -146,8 +150,21 @@ impl Translator for OllamaTranslator {
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send {
|
||||
let config = config.clone();
|
||||
let client = self.client.clone();
|
||||
let parser = self.parser.clone();
|
||||
let language_mapping = self.language_mapping.clone();
|
||||
|
||||
async move {
|
||||
let translator = OllamaTranslator {
|
||||
client,
|
||||
language_mapping,
|
||||
parser,
|
||||
};
|
||||
|
||||
async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>> {
|
||||
let mut translated_sections = Vec::new();
|
||||
let start_time = Instant::now();
|
||||
|
||||
@@ -169,8 +186,8 @@ impl Translator for OllamaTranslator {
|
||||
section // Preserve links
|
||||
} else {
|
||||
// Translate link text only
|
||||
let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), config)?;
|
||||
let translated_text = self.call_ollama(&prompt, config).await?;
|
||||
let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), &config)?;
|
||||
let translated_text = translator.call_ollama(&prompt, &config).await?;
|
||||
MarkdownSection::Link(translated_text.trim().to_string(), url.clone())
|
||||
}
|
||||
}
|
||||
@@ -180,15 +197,15 @@ impl Translator for OllamaTranslator {
|
||||
}
|
||||
MarkdownSection::Table(content) => {
|
||||
println!(" 📊 Translating table content");
|
||||
let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), config)?;
|
||||
let translated_content = self.call_ollama(&prompt, config).await?;
|
||||
let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), &config)?;
|
||||
let translated_content = translator.call_ollama(&prompt, &config).await?;
|
||||
MarkdownSection::Table(translated_content.trim().to_string())
|
||||
}
|
||||
_ => {
|
||||
// Translate text sections
|
||||
println!(" 🔤 Translating text");
|
||||
let prompt = self.build_section_translation_prompt(§ion, config)?;
|
||||
let translated_text = self.call_ollama(&prompt, config).await?;
|
||||
let prompt = translator.build_section_translation_prompt(§ion, &config)?;
|
||||
let translated_text = translator.call_ollama(&prompt, &config).await?;
|
||||
|
||||
match section {
|
||||
MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()),
|
||||
@@ -211,4 +228,5 @@ impl Translator for OllamaTranslator {
|
||||
|
||||
Ok(translated_sections)
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user