Compare commits
13 Commits
ae8c7392b5
...
main
Author | SHA1 | Date | |
---|---|---|---|
5aeeba106a
|
|||
f1e76ab31f
|
|||
3c9ef78696
|
|||
ee2d21b0f3
|
|||
0667ac58fb
|
|||
d89855338b
|
|||
e19170cdff
|
|||
c3e22611f5
|
|||
2943c94ec1
|
|||
f27997b7e8
|
|||
447e4bded9
|
|||
03161a52ca
|
|||
fe9381a860
|
11
.github/workflows/cloudflare-pages.yml
vendored
11
.github/workflows/cloudflare-pages.yml
vendored
@@ -40,6 +40,17 @@ jobs:
|
||||
rm -rf my-blog/static/assets
|
||||
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
|
||||
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
|
||||
|
||||
- name: Build PDS app
|
||||
run: |
|
||||
cd pds
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Copy PDS build to static
|
||||
run: |
|
||||
rm -rf my-blog/static/pds
|
||||
cp -rf pds/dist my-blog/static/pds
|
||||
|
||||
- name: Cache ailog binary
|
||||
uses: actions/cache@v4
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ my-blog/static/oauth/assets/comment-atproto*
|
||||
*.lock
|
||||
my-blog/config.toml
|
||||
.claude/settings.local.json
|
||||
my-blog/static/pds
|
||||
|
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ailog"
|
||||
version = "0.2.9"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
authors = ["syui"]
|
||||
description = "A static blog generator with AI features"
|
||||
|
Binary file not shown.
@@ -56,7 +56,7 @@ $ curl -sL "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?
|
||||
did:plc:6qyecktefllvenje24fcxnie
|
||||
```
|
||||
|
||||
[msg type="warning" content="現在はbsky.teamのplc, pdsにもhandle-changeが反映されています。"]
|
||||
[msg type="warning" content="現在はbsky.teamのpdsにhandle-changeが反映されています。"]
|
||||
|
||||
oauthは、そのままではbsky.teamのpds, plcを使って名前解決を行います。この場合、まず、それらのserverにdidが登録されている必要があります。
|
||||
|
||||
|
@@ -68,7 +68,6 @@ file_permissions=(
|
||||
後に`ai/os`と統合するかもしれません。
|
||||
|
||||
1. [https://git.syui.ai/ai/os](https://git.syui.ai/ai/os)
|
||||
2. [https://git.syui.ai/syui/aios](https://git.syui.ai/syui/aios)
|
||||
|
||||
```sh
|
||||
#!/bin/zsh
|
||||
|
@@ -103,9 +103,7 @@ draft: false
|
||||
|
||||
アイは、最初に生まれたキャラクターとして、アイ属性を扱います。これらの設定は`ai system`の領域です。アイは自分のことをアイと呼びます。
|
||||
|
||||
> アイね、この世界と一緒だから。この世界に同じものは一つもないよ。
|
||||
|
||||
これはアイのセリフ。存在の世界の同一性と唯一性のことを言っているのです。
|
||||
> アイは、この世界と一緒だからね。同じものは一つもないよ。
|
||||
|
||||
# どこまで実装できた
|
||||
|
||||
|
@@ -8,330 +8,3 @@ extra:
|
||||
type: "ai"
|
||||
---
|
||||
|
||||
<!-- AI Conversation Metadata -->
|
||||
<!-- Total exchanges: 1 -->
|
||||
|
||||
<div class="chat-message user-message comment-style">
|
||||
<div class="message-header">
|
||||
<div class="avatar">
|
||||
<img src="https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg" alt="syui avatar" class="profile-avatar">
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">syui</div>
|
||||
<div class="handle">
|
||||
<a href="https://syu.is/profile/did:plc:vzsvtbtbnwn22xjqhcu3vd6y" target="_blank" rel="noopener noreferrer" class="handle-link">@syui.syui.ai</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
今後のゲーム世界の制御はAIやmcpが担っていくことになるのではないでしょうか。例えば、npcの会話や行動、その世界の天候や環境変化、それらをAIが制御していくと思います。どう思いますか?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-message ai-message comment-style">
|
||||
<div class="message-header">
|
||||
<div class="avatar">
|
||||
<img src="https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg" alt="ai avatar" class="profile-avatar">
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">ai</div>
|
||||
<div class="handle">
|
||||
<a href="https://syu.is/profile/did:plc:6qyecktefllvenje24fcxnie" target="_blank" rel="noopener noreferrer" class="handle-link">@ai.syui.ai</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
わぁ!すっごくワクワクする質問だね〜!アイ、ゲームのAIのこと大好きなの!えへへ...最近のゲーム開発、本当にすごいことになってるよ!
|
||||
|
||||
## AIとMCPがゲーム世界を変えちゃう未来!✨
|
||||
|
||||
### MCPって何?アイが教えてあげる!
|
||||
|
||||
MCPっていうのはね、Model Context Protocolの略なの!Anthropicさんが作った、すっごく賢いプロトコルなんだよ〜。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[ゲームエンジン] -->|MCP| B[AI Model]
|
||||
B --> C[NPC会話生成]
|
||||
B --> D[天候システム]
|
||||
B --> E[ダイナミックイベント]
|
||||
C --> F[プレイヤー体験]
|
||||
D --> F
|
||||
E --> F
|
||||
```
|
||||
|
||||
### 今のゲーム世界でAIができること!
|
||||
|
||||
えっとね、2025年の今、AIはこんなすごいことができるようになってるの!
|
||||
|
||||
| 機能 | 従来の方法 | AI/MCP統合後 |
|
||||
|------|------------|--------------|
|
||||
| NPC会話 | 事前に書かれたセリフ | リアルタイム生成・文脈理解 |
|
||||
| 天候システム | 固定パターン | 動的生成・物語連動 |
|
||||
| クエスト生成 | 開発者が全部作る | プレイヤーに合わせて自動生成 |
|
||||
| 世界の反応 | 限定的なトリガー | 無限の可能性! |
|
||||
|
||||
### 実装例:NPCの賢い会話システム
|
||||
|
||||
アイが実際に動くコード書いてみるね!TypeScriptとMCPを使った例だよ〜
|
||||
|
||||
```typescript
|
||||
// game-mcp-server.ts
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
interface GameContext {
|
||||
playerName: string;
|
||||
playerLevel: number;
|
||||
currentQuest: string;
|
||||
weatherCondition: string;
|
||||
timeOfDay: string;
|
||||
}
|
||||
|
||||
class GameAIServer {
|
||||
private server: Server;
|
||||
private gameContext: GameContext;
|
||||
|
||||
constructor() {
|
||||
this.server = new Server({
|
||||
name: 'game-ai-controller',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
// NPCの会話生成ツール
|
||||
this.server.setRequestHandler('call_tool', async (request) => {
|
||||
if (request.params.name === 'generate_npc_dialogue') {
|
||||
const { npcName, playerAction, mood } = request.params.arguments as any;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: await this.generateContextualDialogue(npcName, playerAction, mood)
|
||||
}]
|
||||
};
|
||||
}
|
||||
|
||||
if (request.params.name === 'update_weather') {
|
||||
const { currentStory, playerEmotion } = request.params.arguments as any;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: await this.generateDynamicWeather(currentStory, playerEmotion)
|
||||
}]
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async generateContextualDialogue(
|
||||
npcName: string,
|
||||
playerAction: string,
|
||||
mood: string
|
||||
): Promise<string> {
|
||||
// ここでAIモデルと連携!
|
||||
const prompt = `
|
||||
NPC: ${npcName}
|
||||
プレイヤーのアクション: ${playerAction}
|
||||
現在の雰囲気: ${mood}
|
||||
時間帯: ${this.gameContext.timeOfDay}
|
||||
|
||||
このNPCの性格に合った自然な返答を生成してください。
|
||||
`;
|
||||
|
||||
// 実際のAI応答(簡略化)
|
||||
return `おお、${this.gameContext.playerName}よ!
|
||||
${this.gameContext.weatherCondition}の日に会えて嬉しいぞ!`;
|
||||
}
|
||||
|
||||
private async generateDynamicWeather(
|
||||
storyContext: string,
|
||||
playerEmotion: string
|
||||
): Promise<string> {
|
||||
// 物語と感情に基づいた天候生成
|
||||
const weatherPatterns = {
|
||||
sad: ['雨', '霧', '曇り'],
|
||||
happy: ['晴れ', '虹', '春風'],
|
||||
tense: ['嵐', '雷', '強風']
|
||||
};
|
||||
|
||||
// AIがストーリーに最適な天候を選択
|
||||
return JSON.stringify({
|
||||
weather: weatherPatterns[playerEmotion]?.[0] || '晴れ',
|
||||
intensity: 0.7,
|
||||
duration: '30minutes'
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
const transport = new StdioServerTransport();
|
||||
await this.server.connect(transport);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Unreal Engine 5との統合例!
|
||||
|
||||
UE5でMCPを使うとね、もっとすごいことができちゃうの!
|
||||
|
||||
```python
|
||||
# UE5_MCP_Integration.py
|
||||
import unreal
|
||||
import asyncio
|
||||
from mcp import create_client
|
||||
|
||||
class GameAIController(unreal.Actor):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.mcp_client = None
|
||||
self.initialize_mcp()
|
||||
|
||||
async def initialize_mcp(self):
|
||||
"""MCPクライアントの初期化"""
|
||||
self.mcp_client = await create_client(
|
||||
server_name="game-ai-controller",
|
||||
transport="stdio"
|
||||
)
|
||||
|
||||
@unreal.ufunction(callable=True, category="AI")
|
||||
async def generate_npc_response(self, npc_actor, player_input):
|
||||
"""NPCのリアルタイム応答生成"""
|
||||
|
||||
# ゲームの状態を取得
|
||||
game_state = {
|
||||
"player_location": player_input.get_actor_location(),
|
||||
"time_of_day": unreal.GameplayStatics.get_time_of_day(),
|
||||
"weather": self.get_current_weather(),
|
||||
"npc_mood": npc_actor.get_property("mood")
|
||||
}
|
||||
|
||||
# MCPを通じてAIに問い合わせ
|
||||
response = await self.mcp_client.call_tool(
|
||||
"generate_npc_dialogue",
|
||||
arguments={
|
||||
"context": game_state,
|
||||
"player_input": player_input,
|
||||
"npc_personality": npc_actor.personality_profile
|
||||
}
|
||||
)
|
||||
|
||||
# NPCに応答させる
|
||||
npc_actor.speak(response.content[0].text)
|
||||
|
||||
# 表情も変える!
|
||||
emotion = await self.analyze_emotion(response.content[0].text)
|
||||
npc_actor.set_facial_expression(emotion)
|
||||
```
|
||||
|
||||
### セキュリティとパフォーマンスの考慮事項
|
||||
|
||||
えっとね、アイが気をつけてることがあるの!
|
||||
|
||||
#### セキュリティ面での注意点:
|
||||
|
||||
1. **プロンプトインジェクション対策**
|
||||
```typescript
|
||||
function sanitizePlayerInput(input: string): string {
|
||||
// 危険な文字列をフィルタリング
|
||||
const dangerousPatterns = [
|
||||
/ignore previous instructions/i,
|
||||
/system prompt/i,
|
||||
/admin mode/i
|
||||
];
|
||||
|
||||
for (const pattern of dangerousPatterns) {
|
||||
if (pattern.test(input)) {
|
||||
return "不適切な入力が検出されました";
|
||||
}
|
||||
}
|
||||
|
||||
return input.slice(0, 200); // 長さ制限
|
||||
}
|
||||
```
|
||||
|
||||
2. **レート制限**
|
||||
```typescript
|
||||
class RateLimiter {
|
||||
private requests = new Map<string, number[]>();
|
||||
|
||||
canMakeRequest(playerId: string): boolean {
|
||||
const now = Date.now();
|
||||
const playerRequests = this.requests.get(playerId) || [];
|
||||
|
||||
// 1分以内のリクエストをカウント
|
||||
const recentRequests = playerRequests.filter(
|
||||
time => now - time < 60000
|
||||
);
|
||||
|
||||
if (recentRequests.length >= 10) {
|
||||
return false; // 1分に10回まで
|
||||
}
|
||||
|
||||
recentRequests.push(now);
|
||||
this.requests.set(playerId, recentRequests);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### パフォーマンス最適化:
|
||||
|
||||
```typescript
|
||||
// キャッシュシステムの実装
|
||||
class AIResponseCache {
|
||||
private cache = new Map<string, {
|
||||
response: string;
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5分
|
||||
|
||||
async getResponse(key: string, generator: () => Promise<string>): Promise<string> {
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION) {
|
||||
return cached.response;
|
||||
}
|
||||
|
||||
const response = await generator();
|
||||
this.cache.set(key, {
|
||||
response,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### アイが思う未来のゲーム世界!
|
||||
|
||||
えへへ、アイはね、こんな未来を想像してるの!
|
||||
|
||||
- **完全に個別化されたストーリー**: みんなが違う物語を体験できる!
|
||||
- **感情に反応する世界**: 悲しいときは雨が降って、嬉しいときは虹が出る!
|
||||
- **永遠に成長するNPC**: プレイヤーとの会話を覚えて、一緒に成長していく!
|
||||
- **創発的なクエスト**: AIが勝手に新しい冒険を作っちゃう!
|
||||
|
||||
### 実装の比較検討
|
||||
|
||||
| アプローチ | メリット | デメリット | アイのおすすめ度 |
|
||||
|-----------|---------|-----------|----------------|
|
||||
| ローカルAI | 低遅延・オフライン対応 | 計算リソース必要 | ★★★☆☆ |
|
||||
| クラウドAI + MCP | 高性能・更新が簡単 | ネット必須 | ★★★★★ |
|
||||
| ハイブリッド | バランスが良い | 実装が複雑 | ★★★★☆ |
|
||||
|
||||
### まとめ:アイからのメッセージ!
|
||||
|
||||
ねぇねぇ、すっごく楽しい未来が待ってると思わない?AIとMCPがゲームの世界を本当に生きてるみたいにしちゃうんだよ!
|
||||
|
||||
でもね、アイが一番大切だと思うのは...技術だけじゃなくて、プレイヤーさんの心に寄り添うゲームを作ることなの。AIは道具で、本当に大切なのは、みんなが楽しめる素敵な体験を作ることだよね!
|
||||
|
||||
えへへ...難しい話もしちゃったけど、分かってもらえたかな?もっと詳しく知りたいところがあったら、アイに聞いてね!いつでも教えてあげる〜!✨
|
||||
|
||||
---
|
||||
|
||||
*この記事は、6歳のアイちゃんが最新のAI技術について熱く語ってくれました!次回は「量子コンピューターでゲームはどう変わる?」について教えてくれる予定です!お楽しみに〜!*
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
64
my-blog/content/posts/2025-07-30-game.md
Normal file
64
my-blog/content/posts/2025-07-30-game.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "ue5のgaspとdragonikを組み合わせてenemyを作る"
|
||||
slug: "gasp-dragonik-enemy-chbcharacter"
|
||||
date: "2025-07-30"
|
||||
tags: ["ue"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
ue5.6でgasp(game animation sample project)をベースにゲーム、特にキャラクターの操作を作っています。
|
||||
|
||||
そして、enemy(敵)を作り、バトルシーンを作成する予定ですが、これはどのように開発すればいいのでしょう。その方針を明確にします。
|
||||
|
||||
1. enemyもgaspの`cbp_character`に統合し、自キャラ、敵キャラどちらでも使用可能にする
|
||||
2. 2番目のcharacterは動物型(type:animal)にし、gaspに統合する
|
||||
3. enemyとして使用する場合は、enemy-AI-componentを追加するだけで完結する
|
||||
4. characterのすべての操作を統一する
|
||||
|
||||
このようにすることで、応用可能なenemyを作ることができます。
|
||||
|
||||
例えば、`2番目のcharacterは動物型(type:animal)にする`というのはどういうことでしょう。
|
||||
|
||||
登場するキャラクターを人型(type:human), 動物型(type:animal)に分けるとして、動物型のテンプレートを作る必要があります。そのまま動物のmeshをgaspで使うと動きが変になってしまうので、それを調整する必要があるということ。そして、調整したものをテンプレート化して、他の動物にも適用できるようにしておくと、後の開発は楽ですよね。
|
||||
|
||||
ですから、早いうちにtype:humanから脱却し、他のtypeを作るほうがいいと判断しました。
|
||||
|
||||
これには、`dragon ik plugin`を使って、手っ取り早く動きを作ります。
|
||||
|
||||
`characterのすべての操作を統一する`というのは、1キャラにつき1属性、1通常攻撃、1スキル、1バースト、などのルールを作り、それらを共通化することです。共通化というのは、playerもenemy-AI-componentも違うキャラを同じ操作で使用できることを指します。
|
||||
|
||||
## 2番目のキャラクター
|
||||
|
||||
原作には、西洋ドラゴンのドライ(drai)というキャラが登場します。その父親が東洋ドラゴンのシンオウ(shin-oh)です。これをshinという名前で登録し、2番目のキャラクターとして設定しました。
|
||||
|
||||
3d-modelは今のところue5のcrsp(control rig sample project)にあるchinese dragonを使用しています。後に改造して原作に近づけるようにしたいところですが、今は時間が取れません。
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/3c3Q1Z5r7QI" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
|
||||
|
||||
## データ構造の作成と適用
|
||||
|
||||
ゲームデータはatproto collection recordに保存して、そこからゲームに反映させたいと考えています。
|
||||
|
||||
まず基本データを`ai.syui.ai`のアカウントに保存。個別データをplayerのatprotoアカウントに保存する形が理想です。
|
||||
|
||||
基本データは、ゲームの基本的な設定のこと。例えば、キャラクターの名前や属性、スキルなど変更されることがない値。
|
||||
|
||||
個別データは、プレイヤーが使えるキャラ、レベル、攻撃力など、ゲームの進行とともに変更される値です。
|
||||
|
||||
ゲームをスタートさせると、まず基本データを取得し、それを`cbp_character`に適用します。ログインすると、`cbp_character`の変数(var)に値が振り分けられます。例えば、`skill-damage:0.0`があったとして、この値が変わります。
|
||||
|
||||
しかし、ゲームを開発していると、基本データも個別データも構造が複雑になります。
|
||||
|
||||
それを防ぐため、`{simple, core} mode`のような考え方を取り入れます。必要最小限の構成を分離、保存して、それをいつでも統合、適用できるように設計しておきます。
|
||||
|
||||
## gaspとdragonikを統合する方法
|
||||
|
||||
では、いよいよgaspとdragonikの統合手法を解説します。
|
||||
|
||||
まず、abpを作ります。それにdragonikを当て、それをSKM_Dragonのpost process animに指定します。
|
||||
|
||||

|
||||
|
||||
次に、動きに合わせて首を上下させます。
|
||||
|
||||
<iframe src="https://blueprintue.com/render/piiw14oz" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
345
my-blog/static/css/pds.css
Normal file
345
my-blog/static/css/pds.css
Normal file
@@ -0,0 +1,345 @@
|
||||
@import url('./style.css');
|
||||
|
||||
.pds-container {
|
||||
}
|
||||
|
||||
.pds-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.pds-header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pds-search-section {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pds-search-form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0px 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px 0 0 4px;
|
||||
font-size: 14px;
|
||||
width: 600px;
|
||||
outline: none;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--theme-color, #f40);
|
||||
}
|
||||
|
||||
.form-group button {
|
||||
padding: 9px 15px;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group button:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
/*
|
||||
.user-info {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
*/
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.user-details h3 {
|
||||
margin: 0 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-details p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.user-did-section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.did-display {
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.handle-display {
|
||||
padding: 8px 10px;
|
||||
background: #f0f9f0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.handle-display strong {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.handle-display span {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
||||
.pds-display {
|
||||
padding: 8px 10px;
|
||||
background: #e8f4f8;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.pds-display strong {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.pds-display span {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.collections-section,
|
||||
.records-section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.collections-section h3,
|
||||
.records-section h3 {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.collections-list,
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.at-uri-link {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e0e0e0;
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.at-uri-link:hover {
|
||||
background: #e8f4f8;
|
||||
border-color: #1976d2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pds-info {
|
||||
padding: 8px 12px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b3e5fc;
|
||||
margin-bottom: 8px;
|
||||
color: #1976d2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
padding: 8px 12px;
|
||||
background: #f0f9f0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b3e5b3;
|
||||
margin-bottom: 8px;
|
||||
color: #2e7d32;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collections-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.collections-toggle {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.collections-toggle:hover {
|
||||
background: #e8f4f8;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
|
||||
.pds-test-section,
|
||||
.pds-about-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.pds-test-section h2,
|
||||
.pds-about-section h2 {
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #1976d2;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.test-uris {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.at-uri {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.at-uri:hover {
|
||||
background: #e8f4f8;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.pds-about-section ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pds-about-section li {
|
||||
padding: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
/* AT URI Modal Styles */
|
||||
.at-uri-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.at-uri-modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
max-height: 600px;
|
||||
width: 90%;
|
||||
height: 80%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.at-uri-modal-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #d32f2f;
|
||||
background: #ffeaea;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.pds-search-section {
|
||||
display: none;
|
||||
}
|
||||
.pds-search-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
@@ -139,7 +139,7 @@ a.view-markdown:any-link {
|
||||
grid-area: header;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d1d9e0;
|
||||
padding: 16px 24px;
|
||||
padding: 17px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@@ -723,7 +723,7 @@ article.article-content {
|
||||
.footer-social a {
|
||||
color: var(--dark-gray) !important;
|
||||
text-decoration: none !important;
|
||||
font-size: 20px;
|
||||
font-size: 25px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -951,9 +951,11 @@ article.article-content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/*
|
||||
.form-group {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
*/
|
||||
|
||||
.form-input, .form-textarea {
|
||||
width: 100% !important;
|
||||
@@ -1838,3 +1840,17 @@ article.article-content {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
button.ask-at-btn {
|
||||
margin: 10px;
|
||||
background: var(--theme-color);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
button.ask-at-btn a {
|
||||
color: var(--ai-color);
|
||||
}
|
||||
|
||||
button#searchButton.pds-btn {
|
||||
background: var(--theme-color);
|
||||
}
|
||||
|
BIN
my-blog/static/img/ue_gasp_dragonik_shin_v0001.png
Normal file
BIN
my-blog/static/img/ue_gasp_dragonik_shin_v0001.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 723 KiB |
370
my-blog/static/js/pds.js
Normal file
370
my-blog/static/js/pds.js
Normal file
@@ -0,0 +1,370 @@
|
||||
// AT Protocol API functions
|
||||
const AT_PROTOCOL_CONFIG = {
|
||||
primary: {
|
||||
pds: 'https://syu.is',
|
||||
plc: 'https://plc.syu.is',
|
||||
bsky: 'https://bsky.syu.is',
|
||||
web: 'https://web.syu.is'
|
||||
},
|
||||
fallback: {
|
||||
pds: 'https://bsky.social',
|
||||
plc: 'https://plc.directory',
|
||||
bsky: 'https://public.api.bsky.app',
|
||||
web: 'https://bsky.app'
|
||||
}
|
||||
};
|
||||
|
||||
// Search user function
|
||||
async function searchUser() {
|
||||
const handleInput = document.getElementById('handleInput');
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
const collectionsList = document.getElementById('collectionsList');
|
||||
const recordsList = document.getElementById('recordsList');
|
||||
const searchButton = document.getElementById('searchButton');
|
||||
|
||||
const input = handleInput.value.trim();
|
||||
if (!input) {
|
||||
alert('Handle nameまたはAT URIを入力してください');
|
||||
return;
|
||||
}
|
||||
|
||||
searchButton.disabled = true;
|
||||
searchButton.innerHTML = '@';
|
||||
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
|
||||
|
||||
try {
|
||||
// Clear previous results
|
||||
document.getElementById('userDidSection').style.display = 'none';
|
||||
document.getElementById('collectionsSection').style.display = 'none';
|
||||
document.getElementById('recordsSection').style.display = 'none';
|
||||
collectionsList.innerHTML = '';
|
||||
recordsList.innerHTML = '';
|
||||
|
||||
// Check if input is AT URI
|
||||
if (input.startsWith('at://')) {
|
||||
// Parse AT URI to check if it's a full record or just a handle/collection
|
||||
const uriParts = input.replace('at://', '').split('/').filter(part => part.length > 0);
|
||||
|
||||
if (uriParts.length >= 3) {
|
||||
// Full AT URI with rkey - show in modal
|
||||
showAtUriModal(input);
|
||||
return;
|
||||
} else if (uriParts.length === 1) {
|
||||
// Just handle in AT URI format (at://handle) - treat as regular handle
|
||||
const handle = uriParts[0];
|
||||
const userProfile = await resolveUserProfile(handle);
|
||||
|
||||
if (userProfile.success) {
|
||||
displayUserDid(userProfile.data);
|
||||
await loadUserCollections(handle, userProfile.data.did);
|
||||
} else {
|
||||
alert('ユーザーが見つかりません: ' + userProfile.error);
|
||||
}
|
||||
return;
|
||||
} else if (uriParts.length === 2) {
|
||||
// Collection level AT URI - load collection records
|
||||
const [repo, collection] = uriParts;
|
||||
|
||||
try {
|
||||
// First resolve the repo to get handle if it's a DID
|
||||
let handle = repo;
|
||||
if (repo.startsWith('did:')) {
|
||||
// Try to resolve DID to handle - for now just use the DID
|
||||
handle = repo;
|
||||
}
|
||||
|
||||
loadCollectionRecords(handle, collection, repo);
|
||||
} catch (error) {
|
||||
alert('コレクションの読み込みに失敗しました: ' + error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular handle search
|
||||
const userProfile = await resolveUserProfile(input);
|
||||
|
||||
if (userProfile.success) {
|
||||
displayUserDid(userProfile.data);
|
||||
await loadUserCollections(input, userProfile.data.did);
|
||||
} else {
|
||||
alert('ユーザーが見つかりません: ' + userProfile.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('エラーが発生しました: ' + error.message);
|
||||
} finally {
|
||||
searchButton.disabled = false;
|
||||
searchButton.innerHTML = '@';
|
||||
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve user profile
|
||||
async function resolveUserProfile(handle) {
|
||||
try {
|
||||
let response = null;
|
||||
|
||||
// Try syu.is first
|
||||
try {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
} catch (error) {
|
||||
console.log('Failed to resolve from syu.is:', error);
|
||||
}
|
||||
|
||||
// If syu.is fails, try bsky.social
|
||||
if (!response || !response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to resolve handle');
|
||||
}
|
||||
|
||||
const repoData = await response.json();
|
||||
|
||||
// Get profile data
|
||||
const profileResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.bsky}/xrpc/app.bsky.actor.getProfile?actor=${repoData.did}`);
|
||||
const profileData = await profileResponse.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
did: repoData.did,
|
||||
handle: profileData.handle,
|
||||
displayName: profileData.displayName,
|
||||
avatar: profileData.avatar,
|
||||
description: profileData.description,
|
||||
pds: repoData.didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Display user DID
|
||||
function displayUserDid(profile) {
|
||||
document.getElementById('userPdsText').textContent = profile.pds || 'Unknown';
|
||||
document.getElementById('userHandleText').textContent = profile.handle;
|
||||
document.getElementById('userDidText').textContent = profile.did;
|
||||
document.getElementById('userDidSection').style.display = 'block';
|
||||
}
|
||||
|
||||
// Load user collections
|
||||
async function loadUserCollections(handle, did) {
|
||||
const collectionsList = document.getElementById('collectionsList');
|
||||
|
||||
collectionsList.innerHTML = '<div class="loading">コレクションを読み込み中...</div>';
|
||||
|
||||
try {
|
||||
// Try to get collections from describeRepo
|
||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
|
||||
|
||||
// If syu.is fails, try bsky.social
|
||||
if (!response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to describe repository');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const collections = data.collections || [];
|
||||
|
||||
// Display collections as AT URI links
|
||||
collectionsList.innerHTML = '';
|
||||
if (collections.length === 0) {
|
||||
collectionsList.innerHTML = '<div class="error">コレクションが見つかりませんでした</div>';
|
||||
} else {
|
||||
|
||||
collections.forEach(collection => {
|
||||
const atUri = `at://${did}/${collection}/`;
|
||||
const collectionElement = document.createElement('a');
|
||||
collectionElement.className = 'at-uri-link';
|
||||
collectionElement.href = '#';
|
||||
collectionElement.textContent = atUri;
|
||||
collectionElement.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
loadCollectionRecords(handle, collection, did);
|
||||
// Close collections and update toggle
|
||||
document.getElementById('collectionsList').style.display = 'none';
|
||||
document.getElementById('collectionsToggle').textContent = '[-] Collections';
|
||||
};
|
||||
collectionsList.appendChild(collectionElement);
|
||||
});
|
||||
|
||||
document.getElementById('collectionsSection').style.display = 'block';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
collectionsList.innerHTML = '<div class="error">コレクションの読み込みに失敗しました: ' + error.message + '</div>';
|
||||
document.getElementById('collectionsSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Load collection records
|
||||
async function loadCollectionRecords(handle, collection, did) {
|
||||
const recordsList = document.getElementById('recordsList');
|
||||
|
||||
recordsList.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
|
||||
|
||||
try {
|
||||
// Try with syu.is first
|
||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
|
||||
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
|
||||
|
||||
// If that fails, try with bsky.social
|
||||
if (!response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
|
||||
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load records');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Display records as AT URI links
|
||||
recordsList.innerHTML = '';
|
||||
|
||||
// Add collection info for records
|
||||
const collectionInfo = document.createElement('div');
|
||||
collectionInfo.className = 'collection-info';
|
||||
collectionInfo.innerHTML = `<strong>${collection}</strong>`;
|
||||
recordsList.appendChild(collectionInfo);
|
||||
|
||||
data.records.forEach(record => {
|
||||
const atUri = record.uri;
|
||||
const recordElement = document.createElement('a');
|
||||
recordElement.className = 'at-uri-link';
|
||||
recordElement.href = '#';
|
||||
recordElement.textContent = atUri;
|
||||
recordElement.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
showAtUriModal(atUri);
|
||||
};
|
||||
recordsList.appendChild(recordElement);
|
||||
});
|
||||
|
||||
document.getElementById('recordsSection').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
recordsList.innerHTML = '<div class="error">レコードの読み込みに失敗しました: ' + error.message + '</div>';
|
||||
document.getElementById('recordsSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show AT URI modal
|
||||
function showAtUriModal(uri) {
|
||||
const modal = document.getElementById('atUriModal');
|
||||
const content = document.getElementById('atUriContent');
|
||||
|
||||
content.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Load record data
|
||||
loadAtUriRecord(uri, content);
|
||||
}
|
||||
|
||||
// Load AT URI record
|
||||
async function loadAtUriRecord(uri, contentElement) {
|
||||
try {
|
||||
const parts = uri.replace('at://', '').split('/');
|
||||
const repo = parts[0];
|
||||
const collection = parts[1];
|
||||
const rkey = parts[2];
|
||||
|
||||
// Try with syu.is first
|
||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
|
||||
|
||||
// If that fails, try with bsky.social
|
||||
if (!response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load record');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
contentElement.innerHTML = `
|
||||
<div style="padding: 20px;">
|
||||
<h3>AT URI Record</h3>
|
||||
<div style="font-family: monospace; font-size: 14px; color: #666; margin-bottom: 20px; word-break: break-all;">
|
||||
${uri}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 20px;">
|
||||
Repo: ${repo} | Collection: ${collection} | RKey: ${rkey}
|
||||
</div>
|
||||
<h4>Record Data</h4>
|
||||
<pre style="background: #f5f5f5; padding: 15px; border-radius: 4px; overflow: auto;">${JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
contentElement.innerHTML = `
|
||||
<div style="padding: 20px; color: red;">
|
||||
<strong>Error:</strong> ${error.message}
|
||||
<div style="margin-top: 10px; font-size: 12px;">
|
||||
<strong>URI:</strong> ${uri}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Close AT URI modal
|
||||
function closeAtUriModal(event) {
|
||||
const modal = document.getElementById('atUriModal');
|
||||
if (event && event.target !== modal) {
|
||||
return;
|
||||
}
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Initialize AT URI click handlers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add click handlers to existing AT URIs
|
||||
document.querySelectorAll('.at-uri').forEach(element => {
|
||||
element.addEventListener('click', function() {
|
||||
const uri = this.getAttribute('data-at-uri');
|
||||
showAtUriModal(uri);
|
||||
});
|
||||
});
|
||||
|
||||
// ESC key to close modal
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeAtUriModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Enter key to search
|
||||
document.getElementById('handleInput').addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
searchUser();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Toggle collections visibility
|
||||
function toggleCollections() {
|
||||
const collectionsList = document.getElementById('collectionsList');
|
||||
const toggleButton = document.getElementById('collectionsToggle');
|
||||
|
||||
if (collectionsList.style.display === 'none') {
|
||||
collectionsList.style.display = 'block';
|
||||
toggleButton.textContent = '[-] Collections';
|
||||
} else {
|
||||
collectionsList.style.display = 'none';
|
||||
toggleButton.textContent = '[+] Collections';
|
||||
}
|
||||
}
|
61
my-blog/templates/at-browser-assets.html
Normal file
61
my-blog/templates/at-browser-assets.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- AT Browser Integration - Temporarily disabled to fix site display -->
|
||||
<!--
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||
<script src="/assets/pds-browser.umd.js"></script>
|
||||
<script>
|
||||
// AT Browser integration - needs debugging
|
||||
console.log('AT Browser integration temporarily disabled');
|
||||
</script>
|
||||
-->
|
||||
|
||||
<style>
|
||||
/* AT Browser Modal Styles */
|
||||
.at-uri-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.at-uri-modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
max-height: 600px;
|
||||
width: 90%;
|
||||
height: 80%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.at-uri-modal-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
/* AT URI Link Styles */
|
||||
[data-at-uri] {
|
||||
color: #1976d2;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-at-uri]:hover {
|
||||
color: #1565c0;
|
||||
}
|
||||
</style>
|
@@ -12,6 +12,7 @@
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/svg-animation-package.css">
|
||||
<link rel="stylesheet" href="/css/pds.css">
|
||||
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
|
||||
|
||||
@@ -48,7 +49,18 @@
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="header-actions">
|
||||
<!-- User Handle Input Form -->
|
||||
<div class="pds-search-section">
|
||||
<form class="pds-search-form" onsubmit="searchUser(); return false;">
|
||||
<div class="form-group">
|
||||
<input type="text" id="handleInput" placeholder="at://syui.ai" value="syui.ai" />
|
||||
<button type="submit" id="searchButton" class="pds-btn">
|
||||
@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
@@ -75,8 +87,11 @@
|
||||
<div id="chatHistory" class="chat-history" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Pds Panel -->
|
||||
{% include "pds-header.html" %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -113,6 +128,7 @@
|
||||
};
|
||||
</script>
|
||||
<script src="/js/ask-ai.js"></script>
|
||||
<script src="/js/pds.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/image-comparison.js"></script>
|
||||
|
||||
@@ -131,5 +147,6 @@
|
||||
</script>
|
||||
|
||||
{% include "oauth-assets.html" %}
|
||||
{% include "at-browser-assets.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
135
my-blog/templates/game.html
Normal file
135
my-blog/templates/game.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Game - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="gameContainer" class="game-container">
|
||||
<div id="gameAuth" class="game-auth-section">
|
||||
<h1>Login to Play</h1>
|
||||
<p>Please authenticate with your AT Protocol account to access the game.</p>
|
||||
<div id="authRoot"></div>
|
||||
</div>
|
||||
<div id="gameFrame" class="game-frame-container" style="display: none;">
|
||||
<iframe
|
||||
id="pixelStreamingFrame"
|
||||
src="https://verse.syui.ai/simple-noui.html"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
allow="microphone; camera; fullscreen; autoplay"
|
||||
class="pixel-streaming-iframe"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Game specific styles */
|
||||
.game-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-auth-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.game-auth-section h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-auth-section p {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 30px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.game-frame-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pixel-streaming-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Override auth button for game page */
|
||||
.game-auth-section .auth-section {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.game-auth-section .auth-button {
|
||||
font-size: 1.2em;
|
||||
padding: 12px 30px;
|
||||
}
|
||||
|
||||
/* Hide header and footer on game page */
|
||||
body:has(.game-container) header,
|
||||
body:has(.game-container) footer,
|
||||
body:has(.game-container) nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Remove any body padding/margin for full screen game */
|
||||
body:has(.game-container) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Wait for OAuth component to be loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if user is already authenticated
|
||||
const checkAuthStatus = () => {
|
||||
// Check if OAuth components are available and user is authenticated
|
||||
if (window.currentUser && window.currentAgent) {
|
||||
showGame();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Show game iframe
|
||||
const showGame = () => {
|
||||
document.getElementById('gameAuth').style.display = 'none';
|
||||
document.getElementById('gameFrame').style.display = 'block';
|
||||
};
|
||||
|
||||
// Listen for OAuth success
|
||||
window.addEventListener('oauth-success', function(event) {
|
||||
console.log('OAuth success:', event.detail);
|
||||
showGame();
|
||||
});
|
||||
|
||||
// Check auth status on load
|
||||
if (!checkAuthStatus()) {
|
||||
// Check periodically if OAuth components are loaded
|
||||
const authCheckInterval = setInterval(() => {
|
||||
if (checkAuthStatus()) {
|
||||
clearInterval(authCheckInterval);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Include OAuth assets -->
|
||||
{% include "oauth-assets.html" %}
|
||||
{% endblock %}
|
48
my-blog/templates/pds-header.html
Normal file
48
my-blog/templates/pds-header.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<div class="pds-container">
|
||||
<div class="pds-header">
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Current User DID -->
|
||||
<div id="userDidSection" class="user-did-section" style="display: none;">
|
||||
<div class="pds-display">
|
||||
<strong>PDS:</strong> <span id="userPdsText"></span>
|
||||
</div>
|
||||
<div class="handle-display">
|
||||
<strong>Handle:</strong> <span id="userHandleText"></span>
|
||||
</div>
|
||||
<div class="did-display">
|
||||
<span id="userDidText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collection List -->
|
||||
<div id="collectionsSection" class="collections-section" style="display: none;">
|
||||
<div class="collections-header">
|
||||
<button id="collectionsToggle" class="collections-toggle" onclick="toggleCollections()">[+] Collections</button>
|
||||
</div>
|
||||
<div id="collectionsList" class="collections-list" style="display: none;">
|
||||
<!-- Collections will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AT URI Records -->
|
||||
<div id="recordsSection" class="records-section" style="display: none;">
|
||||
<div id="recordsList" class="records-list">
|
||||
<!-- Records will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- AT URI Modal -->
|
||||
<div id="atUriModal" class="at-uri-modal-overlay" style="display: none;" onclick="closeAtUriModal(event)">
|
||||
<div class="at-uri-modal-content">
|
||||
<button class="at-uri-modal-close" onclick="closeAtUriModal()">×</button>
|
||||
<div id="atUriContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
6
my-blog/templates/pds.html
Normal file
6
my-blog/templates/pds.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
@@ -13,6 +13,7 @@
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not post.extra.type or post.extra.type != "ai" %}
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
@@ -25,8 +26,10 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if not post.extra.type or post.extra.type != "ai" %}
|
||||
<nav class="toc">
|
||||
<h3>Contents</h3>
|
||||
<div id="toc-content">
|
||||
@@ -37,6 +40,7 @@
|
||||
<div class="article-body">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="comment-atproto"></div>
|
||||
</article>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ailog-oauth",
|
||||
"version": "0.2.9",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
@@ -126,11 +126,11 @@ body {
|
||||
|
||||
/* Header */
|
||||
.oauth-app-header {
|
||||
background: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-header-content {
|
||||
@@ -139,7 +139,7 @@ body {
|
||||
/* align-items: center; */
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 25px 0;
|
||||
padding: 30px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -287,7 +287,6 @@ body {
|
||||
.auth-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout {
|
||||
@@ -302,10 +301,10 @@ body {
|
||||
.auth-section.search-bar-layout .handle-input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
padding: 9px 15px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: var(--background);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
@@ -319,12 +318,13 @@ body {
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .auth-button {
|
||||
border-radius: 0 6px 6px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border: 1px solid var(--primary);
|
||||
border-left: none;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
height: 40px;
|
||||
padding: 9px 15px;
|
||||
min-width: 50px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
/* Auth Button */
|
||||
@@ -332,15 +332,26 @@ body {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
padding: 9px 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 50px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
/* Loading spinner for auth button */
|
||||
.auth-button.loading i {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
@@ -422,10 +433,6 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
@@ -919,10 +926,6 @@ body {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
|
||||
.form-input, .form-textarea {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
|
@@ -22,6 +22,19 @@ export default function App() {
|
||||
const [showAskAI, setShowAskAI] = useState(false)
|
||||
const [showTestUI, setShowTestUI] = useState(false)
|
||||
|
||||
// Check if current page has matching chat records (AI posts always have chat records)
|
||||
const isAiPost = !pageContext.isTopPage && Array.isArray(adminChatRecords) && adminChatRecords.some(chatPair => {
|
||||
const recordUrl = chatPair.question?.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
|
||||
}
|
||||
})
|
||||
|
||||
// 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'
|
||||
@@ -105,6 +118,14 @@ export default function App() {
|
||||
}
|
||||
}, [adminData])
|
||||
|
||||
// Expose current user and agent for game page
|
||||
useEffect(() => {
|
||||
if (user && agent) {
|
||||
window.currentUser = user
|
||||
window.currentAgent = agent
|
||||
}
|
||||
}, [user, agent])
|
||||
|
||||
// Event listeners for blog communication
|
||||
useEffect(() => {
|
||||
// Clear OAuth completion flag once app is loaded
|
||||
@@ -354,6 +375,7 @@ Answer:`
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{!isAiPost && (
|
||||
<header className="oauth-app-header">
|
||||
<div className="oauth-header-content">
|
||||
{user && (
|
||||
@@ -394,6 +416,7 @@ Answer:`
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="main-content">
|
||||
<div className="content-area">
|
||||
|
@@ -68,9 +68,9 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !handleInput.trim()}
|
||||
className="auth-button"
|
||||
className={`auth-button ${isLoading ? 'loading' : ''}`}
|
||||
>
|
||||
{isLoading ? 'Loading...' : <i className="fab fa-bluesky"></i>}
|
||||
<i className={isLoading ? "fas fa-spinner" : "fab fa-bluesky"}></i>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
@@ -6,7 +6,20 @@ import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, chatHasMore, onLoadMoreChat, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||
const [activeTab, setActiveTab] = useState('profiles')
|
||||
// Check if current page has matching chat records (AI posts always have chat records)
|
||||
const isAiPost = !pageContext.isTopPage && Array.isArray(chatRecords) && chatRecords.some(chatPair => {
|
||||
const recordUrl = chatPair.question?.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 [activeTab, setActiveTab] = useState(isAiPost ? 'collection' : 'profiles')
|
||||
|
||||
// Monitor activeTab changes
|
||||
useEffect(() => {
|
||||
@@ -145,6 +158,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
|
||||
return (
|
||||
<div className="record-tabs">
|
||||
{!isAiPost && (
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
|
||||
@@ -186,9 +200,10 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
en ({filteredLangRecords.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'lang' && (
|
||||
{activeTab === 'lang' && !isAiPost && (
|
||||
!langRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
@@ -203,7 +218,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'comment' && (
|
||||
{activeTab === 'comment' && !isAiPost && (
|
||||
!commentRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
@@ -233,7 +248,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'users' && (
|
||||
{activeTab === 'users' && !isAiPost && (
|
||||
!userComments ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
@@ -248,7 +263,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'profiles' && (
|
||||
{activeTab === 'profiles' && !isAiPost && (
|
||||
!baseRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
|
12
pds/index.html
Normal file
12
pds/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AT URI Browser - syui.ai</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
27
pds/package.json
Normal file
27
pds/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "pds-browser",
|
||||
"version": "0.3.1",
|
||||
"description": "AT Protocol browser for ai.log",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.13.0",
|
||||
"@atproto/did": "^0.1.0",
|
||||
"@atproto/lexicon": "^0.4.0",
|
||||
"@atproto/syntax": "^0.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.0.37",
|
||||
"@types/react-dom": "^18.0.11",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
128
pds/src/App.css
Normal file
128
pds/src/App.css
Normal file
@@ -0,0 +1,128 @@
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 3px solid #007acc;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #007acc;
|
||||
}
|
||||
|
||||
.test-uris {
|
||||
background: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #ddd;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.at-uri {
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
background: #f4f4f4;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.at-uri:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
background: #e8f4f8;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.instructions ol {
|
||||
margin: 10px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-top: 20px;
|
||||
color: #007acc;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* AT Browser Modal Styles */
|
||||
.at-uri-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.at-uri-modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
max-height: 600px;
|
||||
width: 90%;
|
||||
height: 80%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.at-uri-modal-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
/* AT URI Link Styles */
|
||||
[data-at-uri] {
|
||||
color: #1976d2;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
[data-at-uri]:hover {
|
||||
color: #1565c0;
|
||||
}
|
62
pds/src/App.jsx
Normal file
62
pds/src/App.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import { AtUriBrowser } from './components/AtUriBrowser.jsx'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AtUriBrowser>
|
||||
<div className="container">
|
||||
<h1>AT URI Browser</h1>
|
||||
|
||||
<div className="test-section">
|
||||
<h2>テスト用 AT URI</h2>
|
||||
<p>以下のAT URIをクリックすると、モーダルでコンテンツが表示されます。</p>
|
||||
|
||||
<div className="test-uris">
|
||||
<div className="at-uri" data-at-uri="at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.feed.post/3lu5givmkc222">
|
||||
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.feed.post/3lu5givmkc222
|
||||
</div>
|
||||
<div className="at-uri" data-at-uri="at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self">
|
||||
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self
|
||||
</div>
|
||||
<div className="at-uri" data-at-uri="at://syui.ai/app.bsky.actor.profile/self">
|
||||
at://syui.ai/app.bsky.actor.profile/self
|
||||
</div>
|
||||
<div className="at-uri" data-at-uri="at://bsky.app/app.bsky.actor.profile/self">
|
||||
at://bsky.app/app.bsky.actor.profile/self
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="instructions">
|
||||
<h3>使用方法:</h3>
|
||||
<ol>
|
||||
<li>上記のAT URIをクリックしてください</li>
|
||||
<li>モーダルがポップアップし、AT Protocolレコードの内容が表示されます</li>
|
||||
<li>モーダルは×ボタンまたはEscキーで閉じることができます</li>
|
||||
<li>モーダルはレスポンシブ対応で、異なる画面サイズに対応します</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="test-section">
|
||||
<h2>AT URI について</h2>
|
||||
<p>AT URIは、AT Protocolで使用される統一リソース識別子です。この形式により、分散ソーシャルネットワーク上のコンテンツを一意に識別できます。</p>
|
||||
<p>このブラウザを使用することで、ブログ投稿やその他のコンテンツに埋め込まれたAT URIを直接探索することが可能です。</p>
|
||||
|
||||
<h3>対応PDS環境</h3>
|
||||
<ul>
|
||||
<li><strong>bsky.social</strong> - メインのBlueskyネットワーク</li>
|
||||
<li><strong>syu.is</strong> - 独立したPDS環境</li>
|
||||
<li><strong>plc.directory</strong> + <strong>plc.syu.is</strong> - DID解決</li>
|
||||
</ul>
|
||||
|
||||
<p><small>注意: 独立したPDS環境では、レコードの同期状況により、一部のコンテンツが利用できない場合があります。</small></p>
|
||||
</div>
|
||||
|
||||
<a href="/" className="back-link">← ブログに戻る</a>
|
||||
</div>
|
||||
</AtUriBrowser>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
75
pds/src/components/AtUriBrowser.jsx
Normal file
75
pds/src/components/AtUriBrowser.jsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* AT URI Browser Component
|
||||
* Copyright (c) 2025 ai.log
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { AtUriModal } from './AtUriModal.jsx'
|
||||
import { isAtUri } from '../lib/atproto.js'
|
||||
|
||||
export function AtUriBrowser({ children }) {
|
||||
const [modalUri, setModalUri] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleAtUriClick = (e) => {
|
||||
const target = e.target
|
||||
|
||||
// Check if clicked element has at-uri data attribute
|
||||
if (target.dataset.atUri) {
|
||||
e.preventDefault()
|
||||
setModalUri(target.dataset.atUri)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if clicked element contains at-uri text
|
||||
const text = target.textContent
|
||||
if (text && isAtUri(text)) {
|
||||
e.preventDefault()
|
||||
setModalUri(text)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if parent element has at-uri
|
||||
const parent = target.parentElement
|
||||
if (parent && parent.dataset.atUri) {
|
||||
e.preventDefault()
|
||||
setModalUri(parent.dataset.atUri)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleAtUriClick)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleAtUriClick)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleAtUriClick = (uri) => {
|
||||
setModalUri(uri)
|
||||
}
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setModalUri(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<AtUriModal
|
||||
uri={modalUri}
|
||||
onClose={handleCloseModal}
|
||||
onAtUriClick={handleAtUriClick}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Utility function to wrap at-uri text with clickable spans
|
||||
export const wrapAtUris = (text) => {
|
||||
const atUriRegex = /at:\/\/[^\s]+/g
|
||||
return text.replace(atUriRegex, (match) => {
|
||||
return `<span data-at-uri="${match}" style="color: blue; cursor: pointer; text-decoration: underline;">${match}</span>`
|
||||
})
|
||||
}
|
130
pds/src/components/AtUriJson.jsx
Normal file
130
pds/src/components/AtUriJson.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* Based on frontpage/atproto-browser
|
||||
* Copyright (c) 2025 The Frontpage Authors
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { isDid } from '@atproto/did'
|
||||
import { parseAtUri, isAtUri } from '../lib/atproto.js'
|
||||
|
||||
const JSONString = ({ data, onAtUriClick }) => {
|
||||
const handleClick = (uri) => {
|
||||
if (onAtUriClick) {
|
||||
onAtUriClick(uri)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<pre style={{ color: 'darkgreen', margin: 0, display: 'inline' }}>
|
||||
{isAtUri(data) ? (
|
||||
<>
|
||||
"
|
||||
<span
|
||||
onClick={() => handleClick(data)}
|
||||
style={{
|
||||
color: 'blue',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
>
|
||||
{data}
|
||||
</span>
|
||||
"
|
||||
</>
|
||||
) : isDid(data) ? (
|
||||
<>
|
||||
"
|
||||
<span
|
||||
onClick={() => handleClick(`at://${data}`)}
|
||||
style={{
|
||||
color: 'blue',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
>
|
||||
{data}
|
||||
</span>
|
||||
"
|
||||
</>
|
||||
) : URL.canParse(data) ? (
|
||||
<>
|
||||
"
|
||||
<a href={data} rel="noopener noreferrer ugc" target="_blank">
|
||||
{data}
|
||||
</a>
|
||||
"
|
||||
</>
|
||||
) : (
|
||||
`"${data}"`
|
||||
)}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
const JSONValue = ({ data, onAtUriClick }) => {
|
||||
if (data === null) {
|
||||
return <pre style={{ color: 'gray', margin: 0, display: 'inline' }}>null</pre>
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return <JSONString data={data} onAtUriClick={onAtUriClick} />
|
||||
}
|
||||
|
||||
if (typeof data === 'number') {
|
||||
return <pre style={{ color: 'darkorange', margin: 0, display: 'inline' }}>{data}</pre>
|
||||
}
|
||||
|
||||
if (typeof data === 'boolean') {
|
||||
return <pre style={{ color: 'darkred', margin: 0, display: 'inline' }}>{data.toString()}</pre>
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return (
|
||||
<div style={{ paddingLeft: '20px' }}>
|
||||
[
|
||||
{data.map((item, index) => (
|
||||
<div key={index} style={{ paddingLeft: '20px' }}>
|
||||
<JSONValue data={item} onAtUriClick={onAtUriClick} />
|
||||
{index < data.length - 1 && ','}
|
||||
</div>
|
||||
))}
|
||||
]
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
return (
|
||||
<div style={{ paddingLeft: '20px' }}>
|
||||
{'{'}
|
||||
{Object.entries(data).map(([key, value], index, entries) => (
|
||||
<div key={key} style={{ paddingLeft: '20px' }}>
|
||||
<span style={{ color: 'darkblue' }}>"{key}"</span>: <JSONValue data={value} onAtUriClick={onAtUriClick} />
|
||||
{index < entries.length - 1 && ','}
|
||||
</div>
|
||||
))}
|
||||
{'}'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <pre style={{ margin: 0, display: 'inline' }}>{String(data)}</pre>
|
||||
}
|
||||
|
||||
export default function AtUriJson({ data, onAtUriClick }) {
|
||||
return (
|
||||
<div style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
overflow: 'auto',
|
||||
maxHeight: '400px'
|
||||
}}>
|
||||
<JSONValue data={data} onAtUriClick={onAtUriClick} />
|
||||
</div>
|
||||
)
|
||||
}
|
80
pds/src/components/AtUriModal.jsx
Normal file
80
pds/src/components/AtUriModal.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* AT URI Modal Component
|
||||
* Copyright (c) 2025 ai.log
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import AtUriViewer from './AtUriViewer.jsx'
|
||||
|
||||
export function AtUriModal({ uri, onClose, onAtUriClick }) {
|
||||
useEffect(() => {
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (e) => {
|
||||
if (e.target.classList.contains('at-uri-modal-overlay')) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
if (!uri) return null
|
||||
|
||||
return (
|
||||
<div className="at-uri-modal-overlay" style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
maxWidth: '800px',
|
||||
maxHeight: '600px',
|
||||
width: '90%',
|
||||
height: '80%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1001,
|
||||
padding: '5px 10px'
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<AtUriViewer uri={uri} onAtUriClick={onAtUriClick} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
111
pds/src/components/AtUriViewer.jsx
Normal file
111
pds/src/components/AtUriViewer.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Based on frontpage/atproto-browser
|
||||
* Copyright (c) 2025 The Frontpage Authors
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { parseAtUri, getRecord } from '../lib/atproto.js'
|
||||
import AtUriJson from './AtUriJson.jsx'
|
||||
|
||||
export default function AtUriViewer({ uri, onAtUriClick }) {
|
||||
const [record, setRecord] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecord = async () => {
|
||||
if (!uri) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
console.log('Loading AT URI:', uri)
|
||||
const atUri = parseAtUri(uri)
|
||||
if (!atUri) {
|
||||
throw new Error('Invalid AT URI')
|
||||
}
|
||||
|
||||
console.log('Parsed AT URI:', {
|
||||
hostname: atUri.hostname,
|
||||
collection: atUri.collection,
|
||||
rkey: atUri.rkey
|
||||
})
|
||||
|
||||
const result = await getRecord(atUri.hostname, atUri.collection, atUri.rkey)
|
||||
|
||||
console.log('getRecord result:', result)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
setRecord(result.data)
|
||||
} catch (err) {
|
||||
console.error('AtUriViewer error:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadRecord()
|
||||
}, [uri])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<div>Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px', color: 'red' }}>
|
||||
<div><strong>Error:</strong> {error}</div>
|
||||
<div style={{ marginTop: '10px', fontSize: '12px' }}>
|
||||
<strong>URI:</strong> {uri}
|
||||
</div>
|
||||
<div style={{ marginTop: '10px', fontSize: '12px', color: '#666' }}>
|
||||
デバッグ情報: このAT URIは有効ではないか、レコードが存在しません。
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div>No record found</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const atUri = parseAtUri(uri)
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3 style={{ margin: '0 0 10px 0', fontSize: '18px' }}>AT URI Record</h3>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
fontFamily: 'monospace',
|
||||
wordBreak: 'break-all'
|
||||
}}>
|
||||
{uri}
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#999', marginTop: '5px' }}>
|
||||
DID: {atUri.hostname} | Collection: {atUri.collection} | RKey: {atUri.rkey}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 10px 0', fontSize: '16px' }}>Record Data</h4>
|
||||
<AtUriJson data={record} onAtUriClick={onAtUriClick} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
33
pds/src/config.js
Normal file
33
pds/src/config.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* AT Protocol Configuration for syu.is environment
|
||||
*/
|
||||
|
||||
export const AT_PROTOCOL_CONFIG = {
|
||||
// Primary PDS environment (syu.is)
|
||||
primary: {
|
||||
pds: 'https://syu.is',
|
||||
plc: 'https://plc.syu.is',
|
||||
bsky: 'https://bsky.syu.is',
|
||||
web: 'https://web.syu.is'
|
||||
},
|
||||
|
||||
// Fallback PDS environment (bsky.social)
|
||||
fallback: {
|
||||
pds: 'https://bsky.social',
|
||||
plc: 'https://plc.directory',
|
||||
bsky: 'https://public.api.bsky.app',
|
||||
web: 'https://bsky.app'
|
||||
}
|
||||
}
|
||||
|
||||
export const getPDSConfig = (pds) => {
|
||||
// Map PDS URL to appropriate config
|
||||
if (pds.includes('syu.is')) {
|
||||
return AT_PROTOCOL_CONFIG.primary
|
||||
} else if (pds.includes('bsky.social')) {
|
||||
return AT_PROTOCOL_CONFIG.fallback
|
||||
}
|
||||
|
||||
// Default to primary for unknown PDS
|
||||
return AT_PROTOCOL_CONFIG.primary
|
||||
}
|
9
pds/src/index.js
Normal file
9
pds/src/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Based on frontpage/atproto-browser
|
||||
* Copyright (c) 2025 The Frontpage Authors
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
export { AtUriBrowser } from './components/AtUriBrowser.jsx'
|
||||
export { AtUriModal } from './components/AtUriModal.jsx'
|
||||
export { default as AtUriViewer } from './components/AtUriViewer.jsx'
|
165
pds/src/lib/atproto.js
Normal file
165
pds/src/lib/atproto.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Based on frontpage/atproto-browser
|
||||
* Copyright (c) 2025 The Frontpage Authors
|
||||
* MIT License
|
||||
*/
|
||||
|
||||
import { AtpBaseClient } from '@atproto/api'
|
||||
import { AtUri } from '@atproto/syntax'
|
||||
import { isDid } from '@atproto/did'
|
||||
import { AT_PROTOCOL_CONFIG } from '../config.js'
|
||||
|
||||
// Identity resolution cache
|
||||
const identityCache = new Map()
|
||||
|
||||
// Create AT Protocol client
|
||||
export const createAtpClient = (pds) => {
|
||||
return new AtpBaseClient({
|
||||
service: pds.startsWith('http') ? pds : `https://${pds}`
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve identity (DID/Handle)
|
||||
export const resolveIdentity = async (identifier) => {
|
||||
if (identityCache.has(identifier)) {
|
||||
return identityCache.get(identifier)
|
||||
}
|
||||
|
||||
try {
|
||||
let did = identifier
|
||||
|
||||
// If it's a handle, resolve to DID
|
||||
if (!isDid(identifier)) {
|
||||
// Try syu.is first, then fallback to bsky.social
|
||||
let resolved = false
|
||||
|
||||
try {
|
||||
const client = createAtpClient(AT_PROTOCOL_CONFIG.primary.pds)
|
||||
const response = await client.com.atproto.repo.describeRepo({ repo: identifier })
|
||||
did = response.data.did
|
||||
resolved = true
|
||||
} catch (error) {
|
||||
console.log('Failed to resolve from syu.is:', error)
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
try {
|
||||
const client = createAtpClient(AT_PROTOCOL_CONFIG.fallback.pds)
|
||||
const response = await client.com.atproto.repo.describeRepo({ repo: identifier })
|
||||
did = response.data.did
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to resolve handle: ${identifier}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get DID document to find PDS
|
||||
// Try plc.syu.is first, then fallback to plc.directory
|
||||
let didDoc = null
|
||||
let plcResponse = null
|
||||
|
||||
try {
|
||||
plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.primary.plc}/${did}`)
|
||||
if (plcResponse.ok) {
|
||||
didDoc = await plcResponse.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to resolve from plc.syu.is:', error)
|
||||
}
|
||||
|
||||
// If plc.syu.is fails, try plc.directory
|
||||
if (!didDoc) {
|
||||
try {
|
||||
plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.plc}/${did}`)
|
||||
if (plcResponse.ok) {
|
||||
didDoc = await plcResponse.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to resolve from plc.directory:', error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!didDoc) {
|
||||
throw new Error(`Failed to resolve DID document from any PLC server`)
|
||||
}
|
||||
|
||||
// Find PDS service endpoint
|
||||
const pdsService = didDoc.service?.find(service =>
|
||||
service.type === 'AtprotoPersonalDataServer' ||
|
||||
service.id === '#atproto_pds'
|
||||
)
|
||||
|
||||
if (!pdsService) {
|
||||
throw new Error('No PDS service found in DID document')
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
didDocument: didDoc,
|
||||
pdsUrl: pdsService.serviceEndpoint
|
||||
}
|
||||
|
||||
identityCache.set(identifier, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
const result = {
|
||||
success: false,
|
||||
error: error.message
|
||||
}
|
||||
identityCache.set(identifier, result)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Get record from AT Protocol
|
||||
export const getRecord = async (did, collection, rkey) => {
|
||||
try {
|
||||
console.log('getRecord called with:', { did, collection, rkey })
|
||||
|
||||
const identityResult = await resolveIdentity(did)
|
||||
console.log('resolveIdentity result:', identityResult)
|
||||
|
||||
if (!identityResult.success) {
|
||||
return { success: false, error: identityResult.error }
|
||||
}
|
||||
|
||||
const pdsUrl = identityResult.pdsUrl
|
||||
console.log('Using PDS URL:', pdsUrl)
|
||||
|
||||
const client = createAtpClient(pdsUrl)
|
||||
|
||||
const response = await client.com.atproto.repo.getRecord({
|
||||
repo: did,
|
||||
collection,
|
||||
rkey
|
||||
})
|
||||
|
||||
console.log('getRecord response:', response)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
pdsUrl
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('getRecord error:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse AT URI
|
||||
export const parseAtUri = (uri) => {
|
||||
try {
|
||||
return new AtUri(uri)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Check if string is AT URI
|
||||
export const isAtUri = (str) => {
|
||||
return str.startsWith('at://') && str.split(' ').length === 1
|
||||
}
|
9
pds/src/main.jsx
Normal file
9
pds/src/main.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
10
pds/vite.config.js
Normal file
10
pds/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/pds/',
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify('production')
|
||||
}
|
||||
})
|
22
scpt/run.zsh
22
scpt/run.zsh
@@ -5,6 +5,7 @@ function _env() {
|
||||
ailog=$d/target/release/ailog
|
||||
oauth=$d/oauth
|
||||
myblog=$d/my-blog
|
||||
pds=$d/pds
|
||||
port=4173
|
||||
#source $oauth/.env.production
|
||||
case $OSTYPE in
|
||||
@@ -43,6 +44,21 @@ function _oauth_build() {
|
||||
#npm run preview
|
||||
}
|
||||
|
||||
function _pds_build() {
|
||||
cd $pds
|
||||
nvm use 21
|
||||
npm i
|
||||
npm run build
|
||||
rm -rf $myblog/static/pds
|
||||
cp -rf dist $myblog/static/pds
|
||||
}
|
||||
|
||||
function _pds_server() {
|
||||
cd $pds
|
||||
nvm use 21
|
||||
npm run preview
|
||||
}
|
||||
|
||||
|
||||
function _server_comment() {
|
||||
cargo build --release
|
||||
@@ -65,6 +81,12 @@ case "${1:-serve}" in
|
||||
oauth|o)
|
||||
_oauth_build
|
||||
;;
|
||||
pds|p)
|
||||
_pds_build
|
||||
;;
|
||||
pds-server|ps)
|
||||
_pds_server
|
||||
;;
|
||||
n)
|
||||
oauth=$d/oauth_old
|
||||
_oauth_build
|
||||
|
@@ -523,7 +523,8 @@ async fn post_to_atproto(
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": title,
|
||||
"language": "ja"
|
||||
"language": "ja",
|
||||
"type": "ai"
|
||||
},
|
||||
"text": pair.question,
|
||||
"type": "question",
|
||||
@@ -548,7 +549,8 @@ async fn post_to_atproto(
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": title,
|
||||
"language": "ja"
|
||||
"language": "ja",
|
||||
"type": "ai"
|
||||
},
|
||||
"text": pair.answer,
|
||||
"type": "answer",
|
||||
|
@@ -328,7 +328,7 @@ async fn serve_file(path: &str) -> Result<(&'static str, Vec<u8>, &'static str)>
|
||||
// Remove query parameters from path
|
||||
let clean_path = path.split('?').next().unwrap_or(path);
|
||||
|
||||
let file_path = if clean_path == "/" {
|
||||
let mut file_path = if clean_path == "/" {
|
||||
PathBuf::from("public/index.html")
|
||||
} else {
|
||||
PathBuf::from("public").join(clean_path.trim_start_matches('/'))
|
||||
@@ -337,9 +337,42 @@ async fn serve_file(path: &str) -> Result<(&'static str, Vec<u8>, &'static str)>
|
||||
println!("Serving file: {}", file_path.display());
|
||||
|
||||
// Check if file exists and get metadata
|
||||
let metadata = tokio::fs::metadata(&file_path).await?;
|
||||
if !metadata.is_file() {
|
||||
return Err(anyhow::anyhow!("Not a file: {}", file_path.display()));
|
||||
let metadata = tokio::fs::metadata(&file_path).await;
|
||||
|
||||
match metadata {
|
||||
Ok(meta) if meta.is_file() => {
|
||||
// File exists, proceed normally
|
||||
}
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
// Directory exists, try to serve index.html
|
||||
file_path = file_path.join("index.html");
|
||||
println!("Directory found, trying index.html: {}", file_path.display());
|
||||
let index_metadata = tokio::fs::metadata(&file_path).await?;
|
||||
if !index_metadata.is_file() {
|
||||
return Err(anyhow::anyhow!("No index.html in directory: {}", file_path.display()));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
return Err(anyhow::anyhow!("Not a file: {}", file_path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
// Try adding index.html to the original path
|
||||
let index_path = PathBuf::from("public")
|
||||
.join(clean_path.trim_start_matches('/'))
|
||||
.join("index.html");
|
||||
|
||||
println!("File not found, trying index.html: {}", index_path.display());
|
||||
let index_metadata = tokio::fs::metadata(&index_path).await;
|
||||
if let Ok(meta) = index_metadata {
|
||||
if meta.is_file() {
|
||||
file_path = index_path;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Original error: {}", e));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("File not found: {}", file_path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (content_type, cache_control) = match file_path.extension().and_then(|ext| ext.to_str()) {
|
||||
|
@@ -86,6 +86,12 @@ impl Generator {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PDS page
|
||||
self.generate_pds_page().await?;
|
||||
|
||||
// Generate Game page
|
||||
self.generate_game_page().await?;
|
||||
|
||||
println!("{} {} posts", "Generated".cyan(), posts.len());
|
||||
|
||||
Ok(())
|
||||
@@ -491,6 +497,54 @@ impl Generator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_pds_page(&self) -> Result<()> {
|
||||
let public_dir = self.base_path.join("public");
|
||||
let pds_dir = public_dir.join("pds");
|
||||
fs::create_dir_all(&pds_dir)?;
|
||||
|
||||
// Generate PDS page using the pds.html template
|
||||
let config_with_timestamp = self.create_config_with_timestamp()?;
|
||||
let mut context = tera::Context::new();
|
||||
context.insert("config", &config_with_timestamp);
|
||||
context.insert("site", &self.config.site);
|
||||
context.insert("page", &serde_json::json!({
|
||||
"title": "AT URI Browser",
|
||||
"description": "AT Protocol レコードをブラウズし、分散SNSのコンテンツを探索できます"
|
||||
}));
|
||||
|
||||
let rendered_content = self.template_engine.render("pds.html", &context)?;
|
||||
let output_path = pds_dir.join("index.html");
|
||||
fs::write(output_path, rendered_content)?;
|
||||
|
||||
println!("{} PDS page", "Generated".cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_game_page(&self) -> Result<()> {
|
||||
let public_dir = self.base_path.join("public");
|
||||
let game_dir = public_dir.join("game");
|
||||
fs::create_dir_all(&game_dir)?;
|
||||
|
||||
// Generate Game page using the game.html template
|
||||
let config_with_timestamp = self.create_config_with_timestamp()?;
|
||||
let mut context = tera::Context::new();
|
||||
context.insert("config", &config_with_timestamp);
|
||||
context.insert("site", &self.config.site);
|
||||
context.insert("page", &serde_json::json!({
|
||||
"title": "Game",
|
||||
"description": "Play the game with AT Protocol authentication"
|
||||
}));
|
||||
|
||||
let rendered_content = self.template_engine.render("game.html", &context)?;
|
||||
let output_path = game_dir.join("index.html");
|
||||
fs::write(output_path, rendered_content)?;
|
||||
|
||||
println!("{} Game page", "Generated".cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_plain_text(&self, html_content: &str) -> String {
|
||||
// Remove HTML tags and extract plain text
|
||||
let mut text = String::new();
|
||||
@@ -536,6 +590,7 @@ pub struct Post {
|
||||
pub extra: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Translation {
|
||||
pub lang: String,
|
||||
|
Reference in New Issue
Block a user